From a9778d0df6528223e1b67ee7817694ba2e0b5f53 Mon Sep 17 00:00:00 2001 From: Matt Gardner Date: Wed, 24 Jan 2024 15:32:15 -0700 Subject: [PATCH 01/22] Starting on initial refactor --- Sources/KnockAppDelegate.swift | 90 +++++++++++++++ .../Feed/Models/FeedClientOptions.swift | 104 ++++++++++++++++++ .../Modules/Feed/Models/FeedItemScope.swift | 40 +++++++ 3 files changed, 234 insertions(+) create mode 100644 Sources/KnockAppDelegate.swift create mode 100644 Sources/Modules/Feed/Models/FeedClientOptions.swift create mode 100644 Sources/Modules/Feed/Models/FeedItemScope.swift diff --git a/Sources/KnockAppDelegate.swift b/Sources/KnockAppDelegate.swift new file mode 100644 index 0000000..51c03a6 --- /dev/null +++ b/Sources/KnockAppDelegate.swift @@ -0,0 +1,90 @@ +// +// KnockAppDelegate.swift +// +// +// Created by Matt Gardner on 1/22/24. +// + +import Foundation +import UIKit +import OSLog + +@available(iOSApplicationExtension, unavailable) +open class KnockAppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterDelegate { + + private let logger = Logger(subsystem: "knock-swift", category: "KnockAppDelegate") + + // MARK: Init + + override init() { + super.init() + + // Register to ensure device token can be fetched + UIApplication.shared.registerForRemoteNotifications() + UNUserNotificationCenter.current().delegate = self + + } + + // MARK: Launching + + open func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool { + return true + } + + // MARK: Notifications + + open func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { + logger.debug("userNotificationCenter willPresent notification: \(notification)") + + let userInfo = notification.request.content.userInfo + + let presentationOptions = pushNotificationDeliveredInForeground(userInfo: userInfo) + completionHandler(presentationOptions) + } + + open func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { + logger.debug("didReceiveNotificationResponse: \(response)") + + let userInfo = response.notification.request.content.userInfo + + if response.actionIdentifier == UNNotificationDismissActionIdentifier { + pushNotificationDismissed(userInfo: userInfo) + } else { + pushNotificationTapped(userInfo: userInfo) + } + completionHandler() + } + + // MARK: Token Management + + open func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) { + logger.error("Failed to register for notifications: \(error.localizedDescription)") + } + + open func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { + logger.debug("Successfully registered for notifications!") + + // 1. Convert device token to string + let tokenParts = deviceToken.map { data -> String in + return String(format: "%02.2hhx", data) + } + let token = tokenParts.joined() + // 2. Print device token to use for PNs payloads + logger.debug("Device Token: \(token)") + + let defaults = UserDefaults.standard + defaults.set(token, forKey: "device_push_token") + deviceTokenDidChange(apnsToken: token, isDebugging: isDebuggerAttached) +// self.pushToken = token + } + + // MARK: Functions + + open func deviceTokenDidChange(apnsToken: String, isDebugging: Bool) {} + + open func pushNotificationDeliveredInForeground(userInfo: [AnyHashable : Any]) -> UNNotificationPresentationOptions { return [] } + + open func pushNotificationTapped(userInfo: [AnyHashable : Any]) {} + + open func pushNotificationDismissed(userInfo: [AnyHashable : Any]) {} +} diff --git a/Sources/Modules/Feed/Models/FeedClientOptions.swift b/Sources/Modules/Feed/Models/FeedClientOptions.swift new file mode 100644 index 0000000..353e9bd --- /dev/null +++ b/Sources/Modules/Feed/Models/FeedClientOptions.swift @@ -0,0 +1,104 @@ +// +// FeedClientOptions.swift +// +// +// Created by Matt Gardner on 1/23/24. +// + +import Foundation + +extension Knock.FeedManager { + public struct FeedClientOptions: Codable { + public var before: String? + public var after: String? + public var page_size: Int? + public var status: FeedItemScope? + public var source: String? // Optionally scope all notifications to a particular source only + public var tenant: String? // Optionally scope all requests to a particular tenant + public var has_tenant: Bool? // Optionally scope to notifications with any tenancy or no tenancy + public var archived: FeedItemArchivedScope? // Optionally scope to a given archived status (defaults to `exclude`) + public var trigger_data: [String: AnyCodable]? // GenericData + + public init(from decoder: Decoder) throws { + let container: KeyedDecodingContainer = try decoder.container(keyedBy: Knock.FeedManager.FeedClientOptions.CodingKeys.self) + self.before = try container.decodeIfPresent(String.self, forKey: Knock.FeedManager.FeedClientOptions.CodingKeys.before) + self.after = try container.decodeIfPresent(String.self, forKey: Knock.FeedManager.FeedClientOptions.CodingKeys.after) + self.page_size = try container.decodeIfPresent(Int.self, forKey: Knock.FeedManager.FeedClientOptions.CodingKeys.page_size) + self.status = try container.decodeIfPresent(Knock.FeedManager.FeedItemScope.self, forKey: Knock.FeedManager.FeedClientOptions.CodingKeys.status) + self.source = try container.decodeIfPresent(String.self, forKey: Knock.FeedManager.FeedClientOptions.CodingKeys.source) + self.tenant = try container.decodeIfPresent(String.self, forKey: Knock.FeedManager.FeedClientOptions.CodingKeys.tenant) + self.has_tenant = try container.decodeIfPresent(Bool.self, forKey: Knock.FeedManager.FeedClientOptions.CodingKeys.has_tenant) + self.archived = try container.decodeIfPresent(Knock.FeedManager.FeedItemArchivedScope.self, forKey: Knock.FeedManager.FeedClientOptions.CodingKeys.archived) + self.trigger_data = try container.decodeIfPresent([String : AnyCodable].self, forKey: Knock.FeedManager.FeedClientOptions.CodingKeys.trigger_data) + } + + public init(before: String? = nil, after: String? = nil, page_size: Int? = nil, status: FeedItemScope? = nil, source: String? = nil, tenant: String? = nil, has_tenant: Bool? = nil, archived: FeedItemArchivedScope? = nil, trigger_data: [String : AnyCodable]? = nil) { + self.before = before + self.after = after + self.page_size = page_size + self.status = status + self.source = source + self.tenant = tenant + self.has_tenant = has_tenant + self.archived = archived + self.trigger_data = trigger_data + } + + /** + Returns a new struct of type `FeedClientOptions` with the options passed as the parameter merged into it. + + - Parameters: + - options: the options to merge with the current struct, if they are nil, only a copy of `self` will be returned + */ + public func mergeOptions(options: FeedClientOptions? = nil) -> FeedClientOptions { + // initialize a new `mergedOptions` struct with all the properties of the `self` struct + var mergedOptions = FeedClientOptions( + before: self.before, + after: self.after, + page_size: self.page_size, + status: self.status, + source: self.source, + tenant: self.tenant, + has_tenant: self.has_tenant, + archived: self.archived, + trigger_data: self.trigger_data + ) + + // check if the passed options are not nil + guard let options = options else { + return mergedOptions + } + + // for each one of the properties `not nil` in the parameter `options`, override the ones in the new struct + if options.before != nil { + mergedOptions.before = options.before + } + if options.after != nil { + mergedOptions.after = options.after + } + if options.page_size != nil { + mergedOptions.page_size = options.page_size + } + if options.status != nil { + mergedOptions.status = options.status + } + if options.source != nil { + mergedOptions.source = options.source + } + if options.tenant != nil { + mergedOptions.tenant = options.tenant + } + if options.has_tenant != nil { + mergedOptions.has_tenant = options.has_tenant + } + if options.archived != nil { + mergedOptions.archived = options.archived + } + if options.trigger_data != nil { + mergedOptions.trigger_data = options.trigger_data + } + + return mergedOptions + } + } +} diff --git a/Sources/Modules/Feed/Models/FeedItemScope.swift b/Sources/Modules/Feed/Models/FeedItemScope.swift new file mode 100644 index 0000000..c2f98ad --- /dev/null +++ b/Sources/Modules/Feed/Models/FeedItemScope.swift @@ -0,0 +1,40 @@ +// +// FeedItemScope.swift +// +// +// Created by Matt Gardner on 1/23/24. +// + +import Foundation + + +extension Knock.FeedManager { + public enum FeedItemScope: String, Codable { + // TODO: check engagement_status in https://docs.knock.app/reference#bulk-update-channel-message-status + // extras: + // case archived + // case unarchived + // case interacted + // minus "all" + case all + case unread + case read + case unseen + case seen + } + + public enum FeedItemArchivedScope: String, Codable { + case include + case exclude + case only + } + + public enum BulkChannelMessageStatusUpdateType: String { + case seen + case read + case archived + case unseen + case unread + case unarchived + } +} From db2feabe26d0a1f8501dbe95506072ae16cbf742 Mon Sep 17 00:00:00 2001 From: Matt Gardner Date: Wed, 24 Jan 2024 15:32:25 -0700 Subject: [PATCH 02/22] initial refactor --- Sources/Knock.swift | 103 +++++++++++++----- Sources/KnockAPI.swift | 39 +++---- Sources/Modules/Feed/FeedManager.swift | 134 ++---------------------- Sources/Modules/Users/UserService.swift | 27 ++++- 4 files changed, 130 insertions(+), 173 deletions(-) diff --git a/Sources/Knock.swift b/Sources/Knock.swift index fb9921d..f3f1817 100644 --- a/Sources/Knock.swift +++ b/Sources/Knock.swift @@ -7,37 +7,92 @@ import SwiftUI +//public class Knock { +// public let publishableKey: String +// public let userId: String +// public let userToken: String? +// +// internal let api: KnockAPI +// +// public var feedManager: FeedManager? +// +// public enum KnockError: Error { +// case runtimeError(String) +// } +// +// // MARK: Constructor +// +// /** +// Returns a new instance of the Knock Client +// +// - Parameters: +// - publishableKey: your public API key +// - userId: the user-id that will be used in the subsequent method calls +// - userToken: [optional] user token. Used in production when enhanced security is enabled +// - hostname: [optional] custom hostname of the API, including schema (https://) +// */ +// public init(publishableKey: String, userId: String, userToken: String? = nil, hostname: String? = nil) throws { +// guard publishableKey.hasPrefix("sk_") == false else { throw KnockError.runtimeError("[Knock] You are using your secret API key on the client. Please use the public key.") } +// +// self.publishableKey = publishableKey +// self.userId = userId +// self.userToken = userToken +// +// self.api = KnockAPI(publishableKey: publishableKey, userToken: userToken, hostname: hostname) +// } +//} + + +// Configuration options for the Knock client SDK. +public struct KnockOptions { + var host: String? +} + +// Knock client SDK. public class Knock { public let publishableKey: String - public let userId: String - public let userToken: String? - + public var userId: String? + public var userToken: String? + public var feedManager: FeedManager? + internal let api: KnockAPI + - public var feedManager: FeedManager? + public private(set) static var shared = Knock() - public enum KnockError: Error { - case runtimeError(String) + private let clientVersion = "1.0.0" + + internal var api: KnockAPI! + + public func initialize(apiKey: String, options: KnockOptions? = nil) { + + // Fail loudly if we're using the wrong API key + if apiKey.hasPrefix("sk") { + fatalError("[Knock] You are using your secret API key on the client. Please use the public key.") + } + self.api = KnockAPI(apiKey: apiKey, clientVersion: clientVersion, hostname: options?.host) } - // MARK: Constructor + private func assertInitialized() { + if api == nil { + fatalError("[Knock] You must call initialize() first before trying to make a request...") + } + } - /** - Returns a new instance of the Knock Client - - - Parameters: - - publishableKey: your public API key - - userId: the user-id that will be used in the subsequent method calls - - userToken: [optional] user token. Used in production when enhanced security is enabled - - hostname: [optional] custom hostname of the API, including schema (https://) - */ - public init(publishableKey: String, userId: String, userToken: String? = nil, hostname: String? = nil) throws { - guard publishableKey.hasPrefix("sk_") == false else { throw KnockError.runtimeError("[Knock] You are using your secret API key on the client. Please use the public key.") } - - self.publishableKey = publishableKey - self.userId = userId - self.userToken = userToken - - self.api = KnockAPI(publishableKey: publishableKey, userToken: userToken, hostname: hostname) + private func assertAuthenticated() { + if !isAuthenticated() { + fatalError("[Knock] You must call authenticate() first before trying to make a request...") + } + } + + private func assertAuthAndInit() { + assertInitialized() + assertAuthenticated() + } +} + +extension Knock { + public enum KnockError: Error { + case runtimeError(String) } } diff --git a/Sources/KnockAPI.swift b/Sources/KnockAPI.swift index d943749..a8117e9 100644 --- a/Sources/KnockAPI.swift +++ b/Sources/KnockAPI.swift @@ -8,51 +8,52 @@ import Foundation class KnockAPI { - private let publishableKey: String - private let userToken: String? - public var hostname = "https://api.knock.app" - private var apiBasePath: String { - "\(hostname)/v1" - } - - static let clientVersion = "0.2.0" + internal let apiKey: String + internal let clientVersion: String + internal let apiBasePath: String - init(publishableKey: String, userToken: String? = nil, hostname: String? = nil) { - self.publishableKey = publishableKey + internal var userToken: String? + internal var userId: String? + + internal var host = "https://api.knock.app" + + internal init(apiKey: String, clientVersion: String, hostname: String? = nil, userToken: String? = nil) { + self.apiKey = apiKey self.userToken = userToken - + self.clientVersion = clientVersion if let customHostname = hostname { - self.hostname = customHostname + self.host = customHostname } + apiBasePath = "\(host)/v1" } // MARK: Decode functions, they encapsulate making the request and decoding the data - func decodeFromGet(_ type: T.Type, path: String, queryItems: [URLQueryItem]?, then handler: @escaping (Result) -> Void) { + internal func decodeFromGet(_ type: T.Type, path: String, queryItems: [URLQueryItem]?, then handler: @escaping (Result) -> Void) { get(path: path, queryItems: queryItems) { (result) in self.decodeData(result, handler: handler) } } - func decodeFromPost(_ type: T.Type, path: String, body: Encodable?, then handler: @escaping (Result) -> Void) { + internal func decodeFromPost(_ type: T.Type, path: String, body: Encodable?, then handler: @escaping (Result) -> Void) { post(path: path, body: body) { (result) in self.decodeData(result, handler: handler) } } - func decodeFromPut(_ type: T.Type, path: String, body: Encodable?, then handler: @escaping (Result) -> Void) { + internal func decodeFromPut(_ type: T.Type, path: String, body: Encodable?, then handler: @escaping (Result) -> Void) { put(path: path, body: body) { (result) in self.decodeData(result, handler: handler) } } - func decodeFromDelete(_ type: T.Type, path: String, body: Encodable?, then handler: @escaping (Result) -> Void) { + internal func decodeFromDelete(_ type: T.Type, path: String, body: Encodable?, then handler: @escaping (Result) -> Void) { delete(path: path, body: body) { (result) in self.decodeData(result, handler: handler) } } - private func decodeData(_ result: Result, handler: @escaping (Result) -> Void) { + internal func decodeData(_ result: Result, handler: @escaping (Result) -> Void) { switch result { case .success(let data): let decoder = JSONDecoder() @@ -144,9 +145,9 @@ class KnockAPI { // Headers - request.addValue("knock-swift@\(KnockAPI.clientVersion)", forHTTPHeaderField: "User-Agent") + request.addValue("knock-swift@\(clientVersion)", forHTTPHeaderField: "User-Agent") - request.addValue("Bearer \(publishableKey)", forHTTPHeaderField: "Authorization") + request.addValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") if let userToken = userToken { request.addValue(userToken, forHTTPHeaderField: "X-Knock-User-Token") } diff --git a/Sources/Modules/Feed/FeedManager.swift b/Sources/Modules/Feed/FeedManager.swift index 1f25397..6d43ebf 100644 --- a/Sources/Modules/Feed/FeedManager.swift +++ b/Sources/Modules/Feed/FeedManager.swift @@ -19,138 +19,16 @@ public extension Knock { private var feedTopic: String private var defaultFeedOptions: FeedClientOptions - public enum FeedItemScope: String, Codable { - // TODO: check engagement_status in https://docs.knock.app/reference#bulk-update-channel-message-status - // extras: - // case archived - // case unarchived - // case interacted - // minus "all" - case all - case unread - case read - case unseen - case seen - } - - public enum FeedItemArchivedScope: String, Codable { - case include - case exclude - case only - } - - public struct FeedClientOptions: Codable { - public var before: String? - public var after: String? - public var page_size: Int? - public var status: FeedItemScope? - public var source: String? // Optionally scope all notifications to a particular source only - public var tenant: String? // Optionally scope all requests to a particular tenant - public var has_tenant: Bool? // Optionally scope to notifications with any tenancy or no tenancy - public var archived: FeedItemArchivedScope? // Optionally scope to a given archived status (defaults to `exclude`) - public var trigger_data: [String: AnyCodable]? // GenericData - - public init(from decoder: Decoder) throws { - let container: KeyedDecodingContainer = try decoder.container(keyedBy: Knock.FeedManager.FeedClientOptions.CodingKeys.self) - self.before = try container.decodeIfPresent(String.self, forKey: Knock.FeedManager.FeedClientOptions.CodingKeys.before) - self.after = try container.decodeIfPresent(String.self, forKey: Knock.FeedManager.FeedClientOptions.CodingKeys.after) - self.page_size = try container.decodeIfPresent(Int.self, forKey: Knock.FeedManager.FeedClientOptions.CodingKeys.page_size) - self.status = try container.decodeIfPresent(Knock.FeedManager.FeedItemScope.self, forKey: Knock.FeedManager.FeedClientOptions.CodingKeys.status) - self.source = try container.decodeIfPresent(String.self, forKey: Knock.FeedManager.FeedClientOptions.CodingKeys.source) - self.tenant = try container.decodeIfPresent(String.self, forKey: Knock.FeedManager.FeedClientOptions.CodingKeys.tenant) - self.has_tenant = try container.decodeIfPresent(Bool.self, forKey: Knock.FeedManager.FeedClientOptions.CodingKeys.has_tenant) - self.archived = try container.decodeIfPresent(Knock.FeedManager.FeedItemArchivedScope.self, forKey: Knock.FeedManager.FeedClientOptions.CodingKeys.archived) - self.trigger_data = try container.decodeIfPresent([String : AnyCodable].self, forKey: Knock.FeedManager.FeedClientOptions.CodingKeys.trigger_data) - } - - public init(before: String? = nil, after: String? = nil, page_size: Int? = nil, status: FeedItemScope? = nil, source: String? = nil, tenant: String? = nil, has_tenant: Bool? = nil, archived: FeedItemArchivedScope? = nil, trigger_data: [String : AnyCodable]? = nil) { - self.before = before - self.after = after - self.page_size = page_size - self.status = status - self.source = source - self.tenant = tenant - self.has_tenant = has_tenant - self.archived = archived - self.trigger_data = trigger_data - } - - /** - Returns a new struct of type `FeedClientOptions` with the options passed as the parameter merged into it. - - - Parameters: - - options: the options to merge with the current struct, if they are nil, only a copy of `self` will be returned - */ - public func mergeOptions(options: FeedClientOptions? = nil) -> FeedClientOptions { - // initialize a new `mergedOptions` struct with all the properties of the `self` struct - var mergedOptions = FeedClientOptions( - before: self.before, - after: self.after, - page_size: self.page_size, - status: self.status, - source: self.source, - tenant: self.tenant, - has_tenant: self.has_tenant, - archived: self.archived, - trigger_data: self.trigger_data - ) - - // check if the passed options are not nil - guard let options = options else { - return mergedOptions - } - - // for each one of the properties `not nil` in the parameter `options`, override the ones in the new struct - if options.before != nil { - mergedOptions.before = options.before - } - if options.after != nil { - mergedOptions.after = options.after - } - if options.page_size != nil { - mergedOptions.page_size = options.page_size - } - if options.status != nil { - mergedOptions.status = options.status - } - if options.source != nil { - mergedOptions.source = options.source - } - if options.tenant != nil { - mergedOptions.tenant = options.tenant - } - if options.has_tenant != nil { - mergedOptions.has_tenant = options.has_tenant - } - if options.archived != nil { - mergedOptions.archived = options.archived - } - if options.trigger_data != nil { - mergedOptions.trigger_data = options.trigger_data - } - - return mergedOptions - } - } - - public enum BulkChannelMessageStatusUpdateType: String { - case seen - case read - case archived - case unseen - case unread - case unarchived - } - - public init(client: Knock, feedId: String, options: FeedClientOptions = FeedClientOptions(archived: .exclude)) { + public init(feedId: String, options: FeedClientOptions = FeedClientOptions(archived: .exclude)) throws { + guard let userId = client.userId else { throw Knock.KnockError.runtimeError("Unable to initialize FeedManager without first authenticating Knock user.") } // use regex and circumflex accent to mark only the starting http to be replaced and not any others - let websocketHostname = client.api.hostname.replacingOccurrences(of: "^http", with: "ws", options: .regularExpression) // default: wss://api.knock.app + let websocketHostname = client.api.host.replacingOccurrences(of: "^http", with: "ws", options: .regularExpression) // default: wss://api.knock.app let websocketPath = "\(websocketHostname)/ws/v1/websocket" // default: wss://api.knock.app/ws/v1/websocket - self.socket = Socket(websocketPath, params: ["vsn": "2.0.0", "api_key": client.publishableKey, "user_token": client.userToken ?? ""]) - self.userId = client.userId + self.socket = Socket(websocketPath, params: ["vsn": "2.0.0", "api_key": client.api.apiKey, "user_token": client.api.userToken ?? ""]) + self.userId = userId self.feedId = feedId - self.feedTopic = "feeds:\(feedId):\(client.userId)" + self.feedTopic = "feeds:\(feedId):\(userId)" self.api = client.api self.defaultFeedOptions = options } diff --git a/Sources/Modules/Users/UserService.swift b/Sources/Modules/Users/UserService.swift index 866b2f1..ce50932 100644 --- a/Sources/Modules/Users/UserService.swift +++ b/Sources/Modules/Users/UserService.swift @@ -8,11 +8,34 @@ import Foundation public extension Knock { + + func authenticate(userId: String, userToken: String? = nil, deviceToken: String? = nil, pushChannelId: String? = nil) { + self.api?.userId = userId + self.api?.userToken = userToken + if let token = deviceToken, let channelId = pushChannelId { + self.registerTokenForAPNS(channelId: channelId, token: token) { result in + + } + } + } + + func isAuthenticated(checkUserToken: Bool = false) -> Bool { + if checkUserToken { + return self.api.userId?.isEmpty == false && self.api.userToken?.isEmpty == false + } + return self.api.userId?.isEmpty == false + } + + func logout() { + self.api?.userId = nil + self.api?.userToken = nil + } + func getUser(completionHandler: @escaping ((Result) -> Void)) { - self.api.decodeFromGet(User.self, path: "/users/\(userId)", queryItems: nil, then: completionHandler) + self.api?.decodeFromGet(User.self, path: "/users/\(userId)", queryItems: nil, then: completionHandler) } func updateUser(user: User, completionHandler: @escaping ((Result) -> Void)) { - self.api.decodeFromPut(User.self, path: "/users/\(userId)", body: user, then: completionHandler) + self.api?.decodeFromPut(User.self, path: "/users/\(userId)", body: user, then: completionHandler) } } From 3ec09b2561268ca4abf54976f0e97b0768bc2381 Mon Sep 17 00:00:00 2001 From: Matt Gardner Date: Wed, 24 Jan 2024 16:40:23 -0700 Subject: [PATCH 03/22] Implemented shared Knock instance --- Sources/Knock.swift | 58 ++++++++++++------- Sources/KnockAPI.swift | 29 +++++----- Sources/KnockAppDelegate.swift | 2 +- Sources/Modules/Channels/ChannelService.swift | 12 ++-- Sources/Modules/Feed/FeedManager.swift | 37 ++++++------ .../Preferences/PreferenceService.swift | 6 +- Sources/Modules/Users/UserService.swift | 28 ++++++--- 7 files changed, 94 insertions(+), 78 deletions(-) diff --git a/Sources/Knock.swift b/Sources/Knock.swift index f3f1817..13dcf16 100644 --- a/Sources/Knock.swift +++ b/Sources/Knock.swift @@ -50,19 +50,19 @@ public struct KnockOptions { // Knock client SDK. public class Knock { - public let publishableKey: String - public var userId: String? - public var userToken: String? - public var feedManager: FeedManager? - - internal let api: KnockAPI - + internal static let clientVersion = "1.0.0" + internal static let loggingSubsytem = "knock-swift" public private(set) static var shared = Knock() - - private let clientVersion = "1.0.0" + + public private(set) var publishableKey: String? +// public private(set) var userId: String? + public internal(set) var userId: String? + public internal(set) var userToken: String? + public var feedManager: FeedManager? + - internal var api: KnockAPI! + internal private(set) var api: KnockAPI! public func initialize(apiKey: String, options: KnockOptions? = nil) { @@ -70,24 +70,38 @@ public class Knock { if apiKey.hasPrefix("sk") { fatalError("[Knock] You are using your secret API key on the client. Please use the public key.") } - self.api = KnockAPI(apiKey: apiKey, clientVersion: clientVersion, hostname: options?.host) + self.api = KnockAPI(apiKey: apiKey, hostname: options?.host) } - private func assertInitialized() { - if api == nil { - fatalError("[Knock] You must call initialize() first before trying to make a request...") - } - } +// private func assertInitialized() { +// if api == nil { +// fatalError("[Knock] You must call initialize() first before trying to make a request...") +// } +// } +// +// private func assertAuthenticated() { +// if !isAuthenticated() { +// fatalError("[Knock] You must call authenticate() first before trying to make a request...") +// } +// } +// +// private func assertAuthAndInit() { +// assertInitialized() +// assertAuthenticated() +// } - private func assertAuthenticated() { - if !isAuthenticated() { - fatalError("[Knock] You must call authenticate() first before trying to make a request...") + internal var safePublishableKey: String { + guard let key = publishableKey else { + fatalError("[Knock] You must call Knock.shared.initialize() first before trying to make a request...") } + return key } - private func assertAuthAndInit() { - assertInitialized() - assertAuthenticated() + internal var safeUserId: String { + guard let id = userId else { + fatalError("[Knock] You must call Knock.shared.authenticate() first before trying to make a request where userId is required...") + } + return id } } diff --git a/Sources/KnockAPI.swift b/Sources/KnockAPI.swift index a8117e9..7c04ebf 100644 --- a/Sources/KnockAPI.swift +++ b/Sources/KnockAPI.swift @@ -8,23 +8,15 @@ import Foundation class KnockAPI { - internal let apiKey: String - internal let clientVersion: String - internal let apiBasePath: String - - internal var userToken: String? - internal var userId: String? - - internal var host = "https://api.knock.app" + internal private(set) var host = "https://api.knock.app" + private var apiBasePath: String { + "\(host)/v1" + } - internal init(apiKey: String, clientVersion: String, hostname: String? = nil, userToken: String? = nil) { - self.apiKey = apiKey - self.userToken = userToken - self.clientVersion = clientVersion + internal init(apiKey: String, hostname: String? = nil) { if let customHostname = hostname { self.host = customHostname } - apiBasePath = "\(host)/v1" } // MARK: Decode functions, they encapsulate making the request and decoding the data @@ -124,6 +116,13 @@ class KnockAPI { - then: the code to execute when the response is received */ private func makeGeneralRequest(method: String, path: String, queryItems: [URLQueryItem]?, body: Encodable?, then handler: @escaping (Result) -> Void) { + guard let apiKey = Knock.shared.publishableKey else { + DispatchQueue.main.async { + handler(.failure(Knock.KnockError.runtimeError("Can't make request until Knock.shared.initialize has been called."))) + } + return + } + let sessionConfig = URLSessionConfiguration.default let session = URLSession(configuration: sessionConfig, delegate: nil, delegateQueue: nil) guard var URL = URL(string: "\(apiBasePath)\(path)") else {return} @@ -145,10 +144,10 @@ class KnockAPI { // Headers - request.addValue("knock-swift@\(clientVersion)", forHTTPHeaderField: "User-Agent") + request.addValue("knock-swift@\(Knock.shared.clientVersion)", forHTTPHeaderField: "User-Agent") request.addValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") - if let userToken = userToken { + if let userToken = Knock.shared.userToken { request.addValue(userToken, forHTTPHeaderField: "X-Knock-User-Token") } diff --git a/Sources/KnockAppDelegate.swift b/Sources/KnockAppDelegate.swift index 51c03a6..6b68def 100644 --- a/Sources/KnockAppDelegate.swift +++ b/Sources/KnockAppDelegate.swift @@ -74,7 +74,7 @@ open class KnockAppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificat let defaults = UserDefaults.standard defaults.set(token, forKey: "device_push_token") - deviceTokenDidChange(apnsToken: token, isDebugging: isDebuggerAttached) +// deviceTokenDidChange(apnsToken: token, isDebugging: isDebuggerAttached) // self.pushToken = token } diff --git a/Sources/Modules/Channels/ChannelService.swift b/Sources/Modules/Channels/ChannelService.swift index ca52093..b62ec91 100644 --- a/Sources/Modules/Channels/ChannelService.swift +++ b/Sources/Modules/Channels/ChannelService.swift @@ -6,11 +6,12 @@ // import Foundation +import OSLog public extension Knock { - + func getUserChannelData(channelId: String, completionHandler: @escaping ((Result) -> Void)) { - self.api.decodeFromGet(ChannelData.self, path: "/users/\(userId)/channel_data/\(channelId)", queryItems: nil, then: completionHandler) + self.api.decodeFromGet(ChannelData.self, path: "/users/\(self.safeUserId)/channel_data/\(channelId)", queryItems: nil, then: completionHandler) } /** @@ -24,7 +25,7 @@ public extension Knock { let payload = [ "data": data ] - self.api.decodeFromPut(ChannelData.self, path: "/users/\(userId)/channel_data/\(channelId)", body: payload, then: completionHandler) + self.api.decodeFromPut(ChannelData.self, path: "/users/\(self.safeUserId)/channel_data/\(channelId)", body: payload, then: completionHandler) } /** @@ -64,7 +65,6 @@ public extension Knock { switch result { case .failure(_): // there's no data registered on that channel for that user, we'll create a new record - print("there's no data registered on that channel for that user, we'll create a new record") let data: AnyEncodable = [ "tokens": [token] ] @@ -72,7 +72,6 @@ public extension Knock { case .success(let channelData): guard let data = channelData.data else { // we don't have data for that channel for that user, we'll create a new record - print("we don't have data for that channel for that user, we'll create a new record") let data: AnyEncodable = [ "tokens": [token] ] @@ -82,7 +81,6 @@ public extension Knock { guard var tokens = data["tokens"]?.value as? [String] else { // we don't have an array of valid tokens so we'll register a new one - print("we don't have an array of valid tokens so we'll register a new one") let data: AnyEncodable = [ "tokens": [token] ] @@ -92,12 +90,10 @@ public extension Knock { if tokens.contains(token) { // we already have the token registered - print("we already have the token registered") completionHandler(.success(channelData)) } else { // we need to register the token - print("we need to register the token") tokens.append(token) let data: AnyEncodable = [ "tokens": tokens diff --git a/Sources/Modules/Feed/FeedManager.swift b/Sources/Modules/Feed/FeedManager.swift index 6d43ebf..ce79ac6 100644 --- a/Sources/Modules/Feed/FeedManager.swift +++ b/Sources/Modules/Feed/FeedManager.swift @@ -7,29 +7,26 @@ import Foundation import SwiftPhoenixClient +import OSLog public extension Knock { class FeedManager { - private let api: KnockAPI private let socket: Socket private var feedChannel: Channel? - private let userId: String private let feedId: String private var feedTopic: String private var defaultFeedOptions: FeedClientOptions + private let logger: Logger = Logger(subsystem: Knock.loggingSubsytem, category: "FeedManager") public init(feedId: String, options: FeedClientOptions = FeedClientOptions(archived: .exclude)) throws { - guard let userId = client.userId else { throw Knock.KnockError.runtimeError("Unable to initialize FeedManager without first authenticating Knock user.") } // use regex and circumflex accent to mark only the starting http to be replaced and not any others - let websocketHostname = client.api.host.replacingOccurrences(of: "^http", with: "ws", options: .regularExpression) // default: wss://api.knock.app + let websocketHostname = Knock.shared.api.host.replacingOccurrences(of: "^http", with: "ws", options: .regularExpression) // default: wss://api.knock.app let websocketPath = "\(websocketHostname)/ws/v1/websocket" // default: wss://api.knock.app/ws/v1/websocket - self.socket = Socket(websocketPath, params: ["vsn": "2.0.0", "api_key": client.api.apiKey, "user_token": client.api.userToken ?? ""]) - self.userId = userId + self.socket = Socket(websocketPath, params: ["vsn": "2.0.0", "api_key": Knock.shared.safePublishableKey, "user_token": Knock.shared.userToken ?? ""]) self.feedId = feedId - self.feedTopic = "feeds:\(feedId):\(userId)" - self.api = client.api + self.feedTopic = "feeds:\(feedId):\(Knock.shared.safeUserId)" self.defaultFeedOptions = options } @@ -42,24 +39,24 @@ public extension Knock { public func connectToFeed(options: FeedClientOptions? = nil) { // Setup the socket to receive open/close events socket.delegateOnOpen(to: self) { (self) in - print("Socket Opened") + self.logger.debug("[Knock] Socket Opened") } socket.delegateOnClose(to: self) { (self) in - print("Socket Closed") + self.logger.debug("[Knock] Socket Closed") } socket.delegateOnError(to: self) { (self, error) in let (error, response) = error if let statusCode = (response as? HTTPURLResponse)?.statusCode, statusCode > 400 { - print("Socket Errored. \(statusCode)") + self.logger.error("[Knock] Socket Errored. \(statusCode)") self.socket.disconnect() } else { - print("Socket Errored. \(error)") + self.logger.error("[Knock] Socket Errored. \(error)") } } - socket.logger = { msg in print("LOG:", msg) } +// socket.logger = { msg in print("LOG:", msg) } let mergedOptions = defaultFeedOptions.mergeOptions(options: options) @@ -73,10 +70,10 @@ public extension Knock { self.feedChannel? .join() .delegateReceive("ok", to: self) { (self, _) in - print("CHANNEL: \(channel.topic) joined") + self.logger.debug("[Knock] CHANNEL: \(channel.topic) joined") } .delegateReceive("error", to: self) { (self, message) in - print("CHANNEL: \(channel.topic) failed to join. \(message.payload)") + self.logger.debug("[Knock] CHANNEL: \(channel.topic) failed to join. \(message.payload)") } self.socket.connect() @@ -89,12 +86,12 @@ public extension Knock { } } else { - print("Feed channel is nil. You should call first connectToFeed()") + logger.error("[Knock] Feed channel is nil. You should call first connectToFeed()") } } public func disconnectFromFeed() { - print("Disconnecting from feed") + logger.debug("[Knock] Disconnecting from feed") if let channel = self.feedChannel { channel.leave() @@ -128,7 +125,7 @@ public extension Knock { URLQueryItem(name: "trigger_data", value: triggerDataJSON) ] - api.decodeFromGet(Feed.self, path: "/users/\(userId)/feeds/\(feedId)", queryItems: queryItems) { (result) in + Knock.shared.api.decodeFromGet(Feed.self, path: "/users/\(Knock.shared.safeUserId)/feeds/\(feedId)", queryItems: queryItems) { (result) in completionHandler(result) } } @@ -151,14 +148,14 @@ public extension Knock { // engagement_status: one of `seen`, `unseen`, `read`, `unread`, `archived`, `unarchived`, `interacted` // Also check if the parameters sent here are valid let body: AnyEncodable = [ - "user_ids": [userId], + "user_ids": [Knock.shared.safeUserId], "engagement_status": options.status != nil && options.status != .all ? options.status!.rawValue : "", "archived": options.archived ?? "", "has_tenant": options.has_tenant ?? "", "tenants": (options.tenant != nil) ? [options.tenant!] : "" ] - api.decodeFromPost(BulkOperation.self, path: "/channels/\(feedId)/messages/bulk/\(type.rawValue)", body: body, then: completionHandler) + Knock.shared.api.decodeFromPost(BulkOperation.self, path: "/channels/\(feedId)/messages/bulk/\(type.rawValue)", body: body, then: completionHandler) } private func paramsFromOptions(options: FeedClientOptions) -> [String: Any] { diff --git a/Sources/Modules/Preferences/PreferenceService.swift b/Sources/Modules/Preferences/PreferenceService.swift index f0d3518..b876d29 100644 --- a/Sources/Modules/Preferences/PreferenceService.swift +++ b/Sources/Modules/Preferences/PreferenceService.swift @@ -10,15 +10,15 @@ import Foundation public extension Knock { func getAllUserPreferences(completionHandler: @escaping ((Result<[PreferenceSet], Error>) -> Void)) { - self.api.decodeFromGet([PreferenceSet].self, path: "/users/\(userId)/preferences", queryItems: nil, then: completionHandler) + self.api.decodeFromGet([PreferenceSet].self, path: "/users/\(self.safeUserId)/preferences", queryItems: nil, then: completionHandler) } func getUserPreferences(preferenceId: String, completionHandler: @escaping ((Result) -> Void)) { - self.api.decodeFromGet(PreferenceSet.self, path: "/users/\(userId)/preferences/\(preferenceId)", queryItems: nil, then: completionHandler) + self.api.decodeFromGet(PreferenceSet.self, path: "/users/\(self.safeUserId)/preferences/\(preferenceId)", queryItems: nil, then: completionHandler) } func setUserPreferences(preferenceId: String, preferenceSet: PreferenceSet, completionHandler: @escaping ((Result) -> Void)) { let payload = preferenceSet - self.api.decodeFromPut(PreferenceSet.self, path: "/users/\(userId)/preferences/\(preferenceId)", body: payload, then: completionHandler) + self.api.decodeFromPut(PreferenceSet.self, path: "/users/\(self.safeUserId)/preferences/\(preferenceId)", body: payload, then: completionHandler) } } diff --git a/Sources/Modules/Users/UserService.swift b/Sources/Modules/Users/UserService.swift index ce50932..7476580 100644 --- a/Sources/Modules/Users/UserService.swift +++ b/Sources/Modules/Users/UserService.swift @@ -6,36 +6,46 @@ // import Foundation +import OSLog public extension Knock { + private var logger: Logger { + Logger(subsystem: Knock.loggingSubsytem, category: "UserService") + } + func authenticate(userId: String, userToken: String? = nil, deviceToken: String? = nil, pushChannelId: String? = nil) { - self.api?.userId = userId - self.api?.userToken = userToken + self.userId = userId + self.userToken = userToken if let token = deviceToken, let channelId = pushChannelId { self.registerTokenForAPNS(channelId: channelId, token: token) { result in - + switch result { + case .success(_): + self.logger.debug("success registering the push token with Knock") + case .failure(let error): + self.logger.error("error in registerTokenForAPNS: \(error.localizedDescription)") + } } } } func isAuthenticated(checkUserToken: Bool = false) -> Bool { if checkUserToken { - return self.api.userId?.isEmpty == false && self.api.userToken?.isEmpty == false + return self.userId?.isEmpty == false && self.userToken?.isEmpty == false } - return self.api.userId?.isEmpty == false + return self.userId?.isEmpty == false } func logout() { - self.api?.userId = nil - self.api?.userToken = nil + self.userId = nil + self.userToken = nil } func getUser(completionHandler: @escaping ((Result) -> Void)) { - self.api?.decodeFromGet(User.self, path: "/users/\(userId)", queryItems: nil, then: completionHandler) + self.api?.decodeFromGet(User.self, path: "/users/\(self.safeUserId)", queryItems: nil, then: completionHandler) } func updateUser(user: User, completionHandler: @escaping ((Result) -> Void)) { - self.api?.decodeFromPut(User.self, path: "/users/\(userId)", body: user, then: completionHandler) + self.api?.decodeFromPut(User.self, path: "/users/\(self.safeUserId)", body: user, then: completionHandler) } } From 630ca4dd527f46bf50fbf66884489f24ac716bb4 Mon Sep 17 00:00:00 2001 From: Matt Gardner Date: Thu, 25 Jan 2024 15:36:31 -0700 Subject: [PATCH 04/22] added device unregistration functionality --- Sources/Helpers/Utilities.swift | 7 ++ Sources/Knock.swift | 111 +++++------------- Sources/KnockAPI.swift | 24 ++-- Sources/Modules/Channels/ChannelService.swift | 97 ++++++++++----- Sources/Modules/Feed/FeedManager.swift | 21 ++-- Sources/Modules/Users/UserService.swift | 51 ++++++-- 6 files changed, 176 insertions(+), 135 deletions(-) diff --git a/Sources/Helpers/Utilities.swift b/Sources/Helpers/Utilities.swift index 16bca0c..e8599a5 100644 --- a/Sources/Helpers/Utilities.swift +++ b/Sources/Helpers/Utilities.swift @@ -26,6 +26,13 @@ internal extension Knock { return jsonString } + + static func convertTokenToString(token: Data) -> String { + let tokenParts = token.map { data -> String in + return String(format: "%02.2hhx", data) + } + return tokenParts.joined() + } } struct DynamicCodingKey: CodingKey { diff --git a/Sources/Knock.swift b/Sources/Knock.swift index 13dcf16..759ea4c 100644 --- a/Sources/Knock.swift +++ b/Sources/Knock.swift @@ -7,94 +7,37 @@ import SwiftUI -//public class Knock { -// public let publishableKey: String -// public let userId: String -// public let userToken: String? -// -// internal let api: KnockAPI -// -// public var feedManager: FeedManager? -// -// public enum KnockError: Error { -// case runtimeError(String) -// } -// -// // MARK: Constructor -// -// /** -// Returns a new instance of the Knock Client -// -// - Parameters: -// - publishableKey: your public API key -// - userId: the user-id that will be used in the subsequent method calls -// - userToken: [optional] user token. Used in production when enhanced security is enabled -// - hostname: [optional] custom hostname of the API, including schema (https://) -// */ -// public init(publishableKey: String, userId: String, userToken: String? = nil, hostname: String? = nil) throws { -// guard publishableKey.hasPrefix("sk_") == false else { throw KnockError.runtimeError("[Knock] You are using your secret API key on the client. Please use the public key.") } -// -// self.publishableKey = publishableKey -// self.userId = userId -// self.userToken = userToken -// -// self.api = KnockAPI(publishableKey: publishableKey, userToken: userToken, hostname: hostname) -// } -//} - - -// Configuration options for the Knock client SDK. -public struct KnockOptions { - var host: String? -} - // Knock client SDK. public class Knock { internal static let clientVersion = "1.0.0" internal static let loggingSubsytem = "knock-swift" - public private(set) static var shared = Knock() - - public private(set) var publishableKey: String? -// public private(set) var userId: String? - public internal(set) var userId: String? - public internal(set) var userToken: String? + internal var api: KnockAPI + public var feedManager: FeedManager? + public internal(set) var userId: String? + public internal(set) var pushChannelId: String? + public internal(set) var userDeviceToken: String? - - internal private(set) var api: KnockAPI! - - public func initialize(apiKey: String, options: KnockOptions? = nil) { - - // Fail loudly if we're using the wrong API key - if apiKey.hasPrefix("sk") { - fatalError("[Knock] You are using your secret API key on the client. Please use the public key.") - } - self.api = KnockAPI(apiKey: apiKey, hostname: options?.host) + /** + Returns a new instance of the Knock Client + + - Parameters: + - publishableKey: your public API key + - userId: the user-id that will be used in the subsequent method calls + - userToken: [optional] user token. Used in production when enhanced security is enabled + - options: [optional] Options for customizing the Knock instance. + */ + public init(publishableKey: String, options: KnockOptions?) { + self.api = KnockAPI(publishableKey: publishableKey, hostname: options?.host) } -// private func assertInitialized() { -// if api == nil { -// fatalError("[Knock] You must call initialize() first before trying to make a request...") -// } -// } -// -// private func assertAuthenticated() { -// if !isAuthenticated() { -// fatalError("[Knock] You must call authenticate() first before trying to make a request...") -// } -// } -// -// private func assertAuthAndInit() { -// assertInitialized() -// assertAuthenticated() -// } - - internal var safePublishableKey: String { - guard let key = publishableKey else { - fatalError("[Knock] You must call Knock.shared.initialize() first before trying to make a request...") - } - return key + internal func resetInstance() { + self.userId = nil + self.feedManager = nil + self.userDeviceToken = nil + self.pushChannelId = nil + self.api.updateUserInfo(userToken: nil) } internal var safeUserId: String { @@ -106,7 +49,17 @@ public class Knock { } extension Knock { + // Configuration options for the Knock client SDK. + public struct KnockOptions { + var host: String? + } + public enum KnockError: Error { case runtimeError(String) } } + + +// NoTES: +// Should we provide more safety around userID being invalid? Instead of fatal erroring out the app. +// Ensure that switching api to struct is the right move. diff --git a/Sources/KnockAPI.swift b/Sources/KnockAPI.swift index 7c04ebf..be0668e 100644 --- a/Sources/KnockAPI.swift +++ b/Sources/KnockAPI.swift @@ -7,16 +7,24 @@ import Foundation -class KnockAPI { +struct KnockAPI { + internal let publishableKey: String internal private(set) var host = "https://api.knock.app" + public internal(set) var userToken: String? + private var apiBasePath: String { "\(host)/v1" } - internal init(apiKey: String, hostname: String? = nil) { + internal init(publishableKey: String, hostname: String? = nil) { if let customHostname = hostname { self.host = customHostname } + self.publishableKey = publishableKey + } + + internal mutating func updateUserInfo(userToken: String?) { + self.userToken = userToken } // MARK: Decode functions, they encapsulate making the request and decoding the data @@ -116,12 +124,6 @@ class KnockAPI { - then: the code to execute when the response is received */ private func makeGeneralRequest(method: String, path: String, queryItems: [URLQueryItem]?, body: Encodable?, then handler: @escaping (Result) -> Void) { - guard let apiKey = Knock.shared.publishableKey else { - DispatchQueue.main.async { - handler(.failure(Knock.KnockError.runtimeError("Can't make request until Knock.shared.initialize has been called."))) - } - return - } let sessionConfig = URLSessionConfiguration.default let session = URLSession(configuration: sessionConfig, delegate: nil, delegateQueue: nil) @@ -144,10 +146,10 @@ class KnockAPI { // Headers - request.addValue("knock-swift@\(Knock.shared.clientVersion)", forHTTPHeaderField: "User-Agent") + request.addValue("knock-swift@\(Knock.clientVersion)", forHTTPHeaderField: "User-Agent") - request.addValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") - if let userToken = Knock.shared.userToken { + request.addValue("Bearer \(publishableKey)", forHTTPHeaderField: "Authorization") + if let userToken = userToken { request.addValue(userToken, forHTTPHeaderField: "X-Knock-User-Token") } diff --git a/Sources/Modules/Channels/ChannelService.swift b/Sources/Modules/Channels/ChannelService.swift index b62ec91..96d955a 100644 --- a/Sources/Modules/Channels/ChannelService.swift +++ b/Sources/Modules/Channels/ChannelService.swift @@ -9,7 +9,10 @@ import Foundation import OSLog public extension Knock { - + private var logger: Logger { + Logger(subsystem: Knock.loggingSubsytem, category: "ChannelService") + } + func getUserChannelData(channelId: String, completionHandler: @escaping ((Result) -> Void)) { self.api.decodeFromGet(ChannelData.self, path: "/users/\(self.safeUserId)/channel_data/\(channelId)", queryItems: nil, then: completionHandler) } @@ -28,6 +31,8 @@ public extension Knock { self.api.decodeFromPut(ChannelData.self, path: "/users/\(self.safeUserId)/channel_data/\(channelId)", body: payload, then: completionHandler) } + // Mark: Registration of APNS device tokens + /** Registers an Apple Push Notification Service token so that the device can receive remote push notifications. This is a convenience method that internally gets the channel data and searches for the token. If it exists, then it's already registered and it returns. If the data does not exists or the token is missing from the array, it's added. @@ -41,10 +46,7 @@ public extension Knock { */ func registerTokenForAPNS(channelId: String, token: Data, completionHandler: @escaping ((Result) -> Void)) { // 1. Convert device token to string - let tokenParts = token.map { data -> String in - return String(format: "%02.2hhx", data) - } - let tokenString = tokenParts.joined() + let tokenString = Knock.convertTokenToString(token: token) registerTokenForAPNS(channelId: channelId, token: tokenString, completionHandler: completionHandler) } @@ -61,44 +63,81 @@ public extension Knock { - token: the APNS device token as a `String` */ func registerTokenForAPNS(channelId: String, token: String, completionHandler: @escaping ((Result) -> Void)) { + // Closure to handle token registration/update + let registerOrUpdateToken = { [weak self] (existingTokens: [String]?) in + guard let self = self else { return } + var tokens = existingTokens ?? [] + if !tokens.contains(token) { + tokens.append(token) + } + let data: AnyEncodable = ["tokens": tokens] + self.updateUserChannelData(channelId: channelId, data: data, completionHandler: completionHandler) + } + getUserChannelData(channelId: channelId) { result in switch result { case .failure(_): - // there's no data registered on that channel for that user, we'll create a new record - let data: AnyEncodable = [ - "tokens": [token] - ] - self.updateUserChannelData(channelId: channelId, data: data, completionHandler: completionHandler) + // No data registered on that channel for that user, we'll create a new record + registerOrUpdateToken(nil) + completionHandler(.success(ChannelData.init(channel_id: channelId, data: [:]))) case .success(let channelData): - guard let data = channelData.data else { - // we don't have data for that channel for that user, we'll create a new record - let data: AnyEncodable = [ - "tokens": [token] - ] - self.updateUserChannelData(channelId: channelId, data: data, completionHandler: completionHandler) + guard let data = channelData.data, let tokens = data["tokens"]?.value as? [String] else { + // No valid tokens array found, register a new one + registerOrUpdateToken(nil) return } - guard var tokens = data["tokens"]?.value as? [String] else { - // we don't have an array of valid tokens so we'll register a new one - let data: AnyEncodable = [ - "tokens": [token] - ] - self.updateUserChannelData(channelId: channelId, data: data, completionHandler: completionHandler) - return + if tokens.contains(token) { + // Token already registered + completionHandler(.success(channelData)) + } else { + // Register the new token + registerOrUpdateToken(tokens) + } + } + } + } + + /** + Unregisters the current deviceId associated to the user so that the device will no longer receive remote push notifications for the provided channelId. + + - Parameters: + - channelId: the id of the APNS channel in Knock + - token: the APNS device token as a `String` + */ + + func unregisterTokenForAPNS(channelId: String, token: String, completionHandler: @escaping ((Result) -> Void)) { + getUserChannelData(channelId: channelId) { result in + switch result { + case .failure(let error): + if let networkError = error as? NetworkError, networkError.code == 404 { + // No data registered on that channel for that user + self.logger.warning("[Knock] Could not unregister user from channel \(channelId). Reason: User doesn't have any channel data associated to the provided channelId.") + completionHandler(.success(.init(channel_id: channelId, data: [:]))) + } else { + // Unknown error. Could be network or server related. Try again. + self.logger.error("[Knock] Could not unregister user from channel \(channelId). Please try again. Reason: \(error.localizedDescription)") + completionHandler(.failure(error)) } - if tokens.contains(token) { - // we already have the token registered + case .success(let channelData): + guard let data = channelData.data, let tokens = data["tokens"]?.value as? [String] else { + // No valid tokens array found. + self.logger.warning("[Knock] Could not unregister user from channel \(channelId). Reason: User doesn't have any device tokens associated to the provided channelId.") completionHandler(.success(channelData)) + return } - else { - // we need to register the token - tokens.append(token) + + if tokens.contains(token) { + let newTokensSet = Set(tokens).subtracting([token]) + let newTokens = Array(newTokensSet) let data: AnyEncodable = [ - "tokens": tokens + "tokens": newTokens ] self.updateUserChannelData(channelId: channelId, data: data, completionHandler: completionHandler) + } else { + self.logger.warning("[Knock] Could not unregister user from channel \(channelId). Reason: User doesn't have any device tokens that match the token provided.") + completionHandler(.success(channelData)) } } } diff --git a/Sources/Modules/Feed/FeedManager.swift b/Sources/Modules/Feed/FeedManager.swift index ce79ac6..d72200f 100644 --- a/Sources/Modules/Feed/FeedManager.swift +++ b/Sources/Modules/Feed/FeedManager.swift @@ -10,8 +10,13 @@ import SwiftPhoenixClient import OSLog public extension Knock { + func initializeFeedManager(feedId: String, options: FeedManager.FeedClientOptions = FeedManager.FeedClientOptions(archived: .exclude)) { + self.feedManager = FeedManager(api: self.api, userId: self.safeUserId, feedId: feedId, options: options) + } class FeedManager { + private let api: KnockAPI + private let userId: String private let socket: Socket private var feedChannel: Channel? private let feedId: String @@ -19,14 +24,16 @@ public extension Knock { private var defaultFeedOptions: FeedClientOptions private let logger: Logger = Logger(subsystem: Knock.loggingSubsytem, category: "FeedManager") - public init(feedId: String, options: FeedClientOptions = FeedClientOptions(archived: .exclude)) throws { + internal init(api: KnockAPI, userId: String, feedId: String, options: FeedClientOptions = FeedClientOptions(archived: .exclude)) { // use regex and circumflex accent to mark only the starting http to be replaced and not any others - let websocketHostname = Knock.shared.api.host.replacingOccurrences(of: "^http", with: "ws", options: .regularExpression) // default: wss://api.knock.app + let websocketHostname = api.host.replacingOccurrences(of: "^http", with: "ws", options: .regularExpression) // default: wss://api.knock.app let websocketPath = "\(websocketHostname)/ws/v1/websocket" // default: wss://api.knock.app/ws/v1/websocket - self.socket = Socket(websocketPath, params: ["vsn": "2.0.0", "api_key": Knock.shared.safePublishableKey, "user_token": Knock.shared.userToken ?? ""]) + self.api = api + self.userId = userId + self.socket = Socket(websocketPath, params: ["vsn": "2.0.0", "api_key": api.publishableKey, "user_token": api.userToken ?? ""]) self.feedId = feedId - self.feedTopic = "feeds:\(feedId):\(Knock.shared.safeUserId)" + self.feedTopic = "feeds:\(feedId):\(userId)" self.defaultFeedOptions = options } @@ -125,7 +132,7 @@ public extension Knock { URLQueryItem(name: "trigger_data", value: triggerDataJSON) ] - Knock.shared.api.decodeFromGet(Feed.self, path: "/users/\(Knock.shared.safeUserId)/feeds/\(feedId)", queryItems: queryItems) { (result) in + api.decodeFromGet(Feed.self, path: "/users/\(userId)/feeds/\(feedId)", queryItems: queryItems) { (result) in completionHandler(result) } } @@ -148,14 +155,14 @@ public extension Knock { // engagement_status: one of `seen`, `unseen`, `read`, `unread`, `archived`, `unarchived`, `interacted` // Also check if the parameters sent here are valid let body: AnyEncodable = [ - "user_ids": [Knock.shared.safeUserId], + "user_ids": [userId], "engagement_status": options.status != nil && options.status != .all ? options.status!.rawValue : "", "archived": options.archived ?? "", "has_tenant": options.has_tenant ?? "", "tenants": (options.tenant != nil) ? [options.tenant!] : "" ] - Knock.shared.api.decodeFromPost(BulkOperation.self, path: "/channels/\(feedId)/messages/bulk/\(type.rawValue)", body: body, then: completionHandler) + api.decodeFromPost(BulkOperation.self, path: "/channels/\(feedId)/messages/bulk/\(type.rawValue)", body: body, then: completionHandler) } private func paramsFromOptions(options: FeedClientOptions) -> [String: Any] { diff --git a/Sources/Modules/Users/UserService.swift b/Sources/Modules/Users/UserService.swift index 7476580..38bf7a1 100644 --- a/Sources/Modules/Users/UserService.swift +++ b/Sources/Modules/Users/UserService.swift @@ -14,16 +14,32 @@ public extension Knock { Logger(subsystem: Knock.loggingSubsytem, category: "UserService") } - func authenticate(userId: String, userToken: String? = nil, deviceToken: String? = nil, pushChannelId: String? = nil) { + /** + Sets the user for the current Knock instance. Will also register the device for push notifications if token and channelId are provided. + You can also register the device for push notifications later on by calling Knock.registerTokenForAPNS() + + - Parameters: + - userId: the user-id that will be used in the subsequent method calls + - userToken: [optional] user token. Used in production when enhanced security is enabled + - deviceToken: [optional] Options for customizing the Knock instance. + - pushChannelId: [optional] Options for customizing the Knock instance. + */ + func authenticate(userId: String, userToken: String? = nil, deviceToken: String? = nil, pushChannelId: String? = nil, completion: @escaping (Result) -> Void) { self.userId = userId - self.userToken = userToken + self.pushChannelId = pushChannelId + self.userDeviceToken = deviceToken + self.api.updateUserInfo(userToken: userToken) if let token = deviceToken, let channelId = pushChannelId { self.registerTokenForAPNS(channelId: channelId, token: token) { result in switch result { case .success(_): + self.pushChannelId = pushChannelId + self.userDeviceToken = deviceToken self.logger.debug("success registering the push token with Knock") + completion(.success(())) case .failure(let error): - self.logger.error("error in registerTokenForAPNS: \(error.localizedDescription)") + self.logger.error("Error in registerTokenForAPNS: \(error.localizedDescription)") + completion(.failure(error)) } } } @@ -31,21 +47,38 @@ public extension Knock { func isAuthenticated(checkUserToken: Bool = false) -> Bool { if checkUserToken { - return self.userId?.isEmpty == false && self.userToken?.isEmpty == false + return self.userId?.isEmpty == false && self.api.userToken?.isEmpty == false } return self.userId?.isEmpty == false } - func logout() { - self.userId = nil - self.userToken = nil + /** + Sets the user for the current Knock instance. Will also register the device for push notifications if token and channelId are provided. + You can also register the device for push notifications later on by calling Knock.registerTokenForAPNS() + */ + func logout(completion: @escaping (Result) -> Void) { + guard let channelId = self.pushChannelId, let token = self.userDeviceToken else { + self.resetInstance() + completion(.success(())) + return + } + self.unregisterTokenForAPNS(channelId: channelId, token: token) { result in + switch result { + case .success(_): + self.resetInstance() + completion(.success(())) + case .failure(let error): + // Don't reset data if there was an error in the unregistration step. That way the client can retry the logout if they want. + completion(.failure(error)) + } + } } func getUser(completionHandler: @escaping ((Result) -> Void)) { - self.api?.decodeFromGet(User.self, path: "/users/\(self.safeUserId)", queryItems: nil, then: completionHandler) + self.api.decodeFromGet(User.self, path: "/users/\(self.safeUserId)", queryItems: nil, then: completionHandler) } func updateUser(user: User, completionHandler: @escaping ((Result) -> Void)) { - self.api?.decodeFromPut(User.self, path: "/users/\(self.safeUserId)", body: user, then: completionHandler) + self.api.decodeFromPut(User.self, path: "/users/\(self.safeUserId)", body: user, then: completionHandler) } } From 4f149052b330500d93ef8ad01d2e48ae1d201a11 Mon Sep 17 00:00:00 2001 From: Matt Gardner Date: Thu, 25 Jan 2024 16:14:13 -0700 Subject: [PATCH 05/22] Added more safety around the userId checks --- Sources/Knock.swift | 27 ++++++++++++------- Sources/KnockAPI.swift | 6 +---- Sources/Modules/Channels/ChannelService.swift | 14 ++++++---- Sources/Modules/Feed/FeedManager.swift | 10 +++++-- .../Preferences/PreferenceService.swift | 14 +++++++--- Sources/Modules/Users/UserService.swift | 10 ++++--- 6 files changed, 53 insertions(+), 28 deletions(-) diff --git a/Sources/Knock.swift b/Sources/Knock.swift index 759ea4c..3c3276d 100644 --- a/Sources/Knock.swift +++ b/Sources/Knock.swift @@ -14,7 +14,7 @@ public class Knock { internal var api: KnockAPI - public var feedManager: FeedManager? + public internal(set) var feedManager: FeedManager? public internal(set) var userId: String? public internal(set) var pushChannelId: String? public internal(set) var userDeviceToken: String? @@ -37,15 +37,15 @@ public class Knock { self.feedManager = nil self.userDeviceToken = nil self.pushChannelId = nil - self.api.updateUserInfo(userToken: nil) + self.api.userToken = nil } - internal var safeUserId: String { - guard let id = userId else { - fatalError("[Knock] You must call Knock.shared.authenticate() first before trying to make a request where userId is required...") - } - return id - } +// internal var safeUserId: String { +// guard let id = userId else { +// fatalError("[Knock] You must call Knock.shared.authenticate() first before trying to make a request where userId is required...") +// } +// return id +// } } extension Knock { @@ -56,10 +56,19 @@ extension Knock { public enum KnockError: Error { case runtimeError(String) + case userIdError +// "UserId not found. Please authenticate your userId with Knock.authenticate()." + } + + func performActionWithUserId(_ action: @escaping (String, @escaping (Result) -> Void) -> Void, completionHandler: @escaping (Result) -> Void) { + guard let userId = self.userId else { + completionHandler(.failure(KnockError.userIdError)) + return + } + action(userId, completionHandler) } } // NoTES: // Should we provide more safety around userID being invalid? Instead of fatal erroring out the app. -// Ensure that switching api to struct is the right move. diff --git a/Sources/KnockAPI.swift b/Sources/KnockAPI.swift index be0668e..b78fdec 100644 --- a/Sources/KnockAPI.swift +++ b/Sources/KnockAPI.swift @@ -7,7 +7,7 @@ import Foundation -struct KnockAPI { +class KnockAPI { internal let publishableKey: String internal private(set) var host = "https://api.knock.app" public internal(set) var userToken: String? @@ -23,10 +23,6 @@ struct KnockAPI { self.publishableKey = publishableKey } - internal mutating func updateUserInfo(userToken: String?) { - self.userToken = userToken - } - // MARK: Decode functions, they encapsulate making the request and decoding the data internal func decodeFromGet(_ type: T.Type, path: String, queryItems: [URLQueryItem]?, then handler: @escaping (Result) -> Void) { diff --git a/Sources/Modules/Channels/ChannelService.swift b/Sources/Modules/Channels/ChannelService.swift index 96d955a..efe98c1 100644 --- a/Sources/Modules/Channels/ChannelService.swift +++ b/Sources/Modules/Channels/ChannelService.swift @@ -14,7 +14,9 @@ public extension Knock { } func getUserChannelData(channelId: String, completionHandler: @escaping ((Result) -> Void)) { - self.api.decodeFromGet(ChannelData.self, path: "/users/\(self.safeUserId)/channel_data/\(channelId)", queryItems: nil, then: completionHandler) + performActionWithUserId( { userId, completion in + self.api.decodeFromGet(ChannelData.self, path: "/users/\(userId)/channel_data/\(channelId)", queryItems: nil, then: completion) + }, completionHandler: completionHandler) } /** @@ -25,10 +27,12 @@ public extension Knock { - data: the shape of the payload varies depending on the channel. You can learn more about channel data schemas [here](https://docs.knock.app/send-notifications/setting-channel-data#provider-data-requirements). */ func updateUserChannelData(channelId: String, data: AnyEncodable, completionHandler: @escaping ((Result) -> Void)) { - let payload = [ - "data": data - ] - self.api.decodeFromPut(ChannelData.self, path: "/users/\(self.safeUserId)/channel_data/\(channelId)", body: payload, then: completionHandler) + performActionWithUserId( { userId, completion in + let payload = [ + "data": data + ] + self.api.decodeFromPut(ChannelData.self, path: "/users/\(userId)/channel_data/\(channelId)", body: payload, then: completion) + }, completionHandler: completionHandler) } // Mark: Registration of APNS device tokens diff --git a/Sources/Modules/Feed/FeedManager.swift b/Sources/Modules/Feed/FeedManager.swift index d72200f..5e94b45 100644 --- a/Sources/Modules/Feed/FeedManager.swift +++ b/Sources/Modules/Feed/FeedManager.swift @@ -10,8 +10,13 @@ import SwiftPhoenixClient import OSLog public extension Knock { - func initializeFeedManager(feedId: String, options: FeedManager.FeedClientOptions = FeedManager.FeedClientOptions(archived: .exclude)) { - self.feedManager = FeedManager(api: self.api, userId: self.safeUserId, feedId: feedId, options: options) + func initializeFeedManager(feedId: String, options: FeedManager.FeedClientOptions = FeedManager.FeedClientOptions(archived: .exclude)) throws { + guard let safeUserId = self.userId else { throw KnockError.userIdError } + self.feedManager = FeedManager(api: self.api, userId: safeUserId, feedId: feedId, options: options) + } + + func deInitializeFeedManager() { + self.feedManager = nil } class FeedManager { @@ -63,6 +68,7 @@ public extension Knock { } } + // TODO: Determine the level of logging we want from SwiftPhoenixClient. Currently this produces a lot of noise. // socket.logger = { msg in print("LOG:", msg) } let mergedOptions = defaultFeedOptions.mergeOptions(options: options) diff --git a/Sources/Modules/Preferences/PreferenceService.swift b/Sources/Modules/Preferences/PreferenceService.swift index b876d29..f2c431a 100644 --- a/Sources/Modules/Preferences/PreferenceService.swift +++ b/Sources/Modules/Preferences/PreferenceService.swift @@ -10,15 +10,21 @@ import Foundation public extension Knock { func getAllUserPreferences(completionHandler: @escaping ((Result<[PreferenceSet], Error>) -> Void)) { - self.api.decodeFromGet([PreferenceSet].self, path: "/users/\(self.safeUserId)/preferences", queryItems: nil, then: completionHandler) + performActionWithUserId( { userId, completion in + self.api.decodeFromGet([PreferenceSet].self, path: "/users/\(userId)/preferences", queryItems: nil, then: completion) + }, completionHandler: completionHandler) } func getUserPreferences(preferenceId: String, completionHandler: @escaping ((Result) -> Void)) { - self.api.decodeFromGet(PreferenceSet.self, path: "/users/\(self.safeUserId)/preferences/\(preferenceId)", queryItems: nil, then: completionHandler) + performActionWithUserId( { userId, completion in + self.api.decodeFromGet(PreferenceSet.self, path: "/users/\(userId)/preferences/\(preferenceId)", queryItems: nil, then: completion) + }, completionHandler: completionHandler) } func setUserPreferences(preferenceId: String, preferenceSet: PreferenceSet, completionHandler: @escaping ((Result) -> Void)) { - let payload = preferenceSet - self.api.decodeFromPut(PreferenceSet.self, path: "/users/\(self.safeUserId)/preferences/\(preferenceId)", body: payload, then: completionHandler) + performActionWithUserId( { userId, completion in + let payload = preferenceSet + self.api.decodeFromPut(PreferenceSet.self, path: "/users/\(userId)/preferences/\(preferenceId)", body: payload, then: completion) + }, completionHandler: completionHandler) } } diff --git a/Sources/Modules/Users/UserService.swift b/Sources/Modules/Users/UserService.swift index 38bf7a1..6889217 100644 --- a/Sources/Modules/Users/UserService.swift +++ b/Sources/Modules/Users/UserService.swift @@ -28,7 +28,7 @@ public extension Knock { self.userId = userId self.pushChannelId = pushChannelId self.userDeviceToken = deviceToken - self.api.updateUserInfo(userToken: userToken) + self.api.userToken = userToken if let token = deviceToken, let channelId = pushChannelId { self.registerTokenForAPNS(channelId: channelId, token: token) { result in switch result { @@ -75,10 +75,14 @@ public extension Knock { } func getUser(completionHandler: @escaping ((Result) -> Void)) { - self.api.decodeFromGet(User.self, path: "/users/\(self.safeUserId)", queryItems: nil, then: completionHandler) + performActionWithUserId( { userId, completion in + self.api.decodeFromGet(User.self, path: "/users/\(userId)", queryItems: nil, then: completion) + }, completionHandler: completionHandler) } func updateUser(user: User, completionHandler: @escaping ((Result) -> Void)) { - self.api.decodeFromPut(User.self, path: "/users/\(self.safeUserId)", body: user, then: completionHandler) + performActionWithUserId( { userId, completion in + self.api.decodeFromPut(User.self, path: "/users/\(userId)", body: user, then: completion) + }, completionHandler: completionHandler) } } From 133770fc70caa460be0429fe3f5519686b5b6246 Mon Sep 17 00:00:00 2001 From: Matt Gardner Date: Thu, 25 Jan 2024 16:22:26 -0700 Subject: [PATCH 06/22] Cleanup --- Sources/Helpers/Utilities.swift | 8 ++++++++ Sources/Knock.swift | 15 --------------- 2 files changed, 8 insertions(+), 15 deletions(-) diff --git a/Sources/Helpers/Utilities.swift b/Sources/Helpers/Utilities.swift index e8599a5..c40fc0d 100644 --- a/Sources/Helpers/Utilities.swift +++ b/Sources/Helpers/Utilities.swift @@ -33,6 +33,14 @@ internal extension Knock { } return tokenParts.joined() } + + func performActionWithUserId(_ action: @escaping (String, @escaping (Result) -> Void) -> Void, completionHandler: @escaping (Result) -> Void) { + guard let userId = self.userId else { + completionHandler(.failure(KnockError.userIdError)) + return + } + action(userId, completionHandler) + } } struct DynamicCodingKey: CodingKey { diff --git a/Sources/Knock.swift b/Sources/Knock.swift index 3c3276d..5566b0b 100644 --- a/Sources/Knock.swift +++ b/Sources/Knock.swift @@ -39,13 +39,6 @@ public class Knock { self.pushChannelId = nil self.api.userToken = nil } - -// internal var safeUserId: String { -// guard let id = userId else { -// fatalError("[Knock] You must call Knock.shared.authenticate() first before trying to make a request where userId is required...") -// } -// return id -// } } extension Knock { @@ -59,14 +52,6 @@ extension Knock { case userIdError // "UserId not found. Please authenticate your userId with Knock.authenticate()." } - - func performActionWithUserId(_ action: @escaping (String, @escaping (Result) -> Void) -> Void, completionHandler: @escaping (Result) -> Void) { - guard let userId = self.userId else { - completionHandler(.failure(KnockError.userIdError)) - return - } - action(userId, completionHandler) - } } From fbc7ff4b549ebfbf79a71d9129d5761d5e101289 Mon Sep 17 00:00:00 2001 From: Matt Gardner Date: Thu, 25 Jan 2024 16:47:37 -0700 Subject: [PATCH 07/22] Make KnockError a LocalizedError --- Sources/Knock.swift | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/Sources/Knock.swift b/Sources/Knock.swift index 5566b0b..a6e4780 100644 --- a/Sources/Knock.swift +++ b/Sources/Knock.swift @@ -45,15 +45,25 @@ extension Knock { // Configuration options for the Knock client SDK. public struct KnockOptions { var host: String? + + init(host: String? = nil) { + self.host = host + } } public enum KnockError: Error { case runtimeError(String) case userIdError -// "UserId not found. Please authenticate your userId with Knock.authenticate()." } } - -// NoTES: -// Should we provide more safety around userID being invalid? Instead of fatal erroring out the app. +extension Knock.KnockError: LocalizedError { + public var errorDescription: String? { + switch self { + case .runtimeError(let message): + return message + case .userIdError: + return "UserId not found. Please authenticate your userId with Knock.authenticate()." + } + } +} From 30a58b5d8dfd6e4aa2b6aa417fc396bf3ed2368f Mon Sep 17 00:00:00 2001 From: Matt Gardner Date: Thu, 25 Jan 2024 16:54:22 -0700 Subject: [PATCH 08/22] Updated KnockOptions init --- Sources/Knock.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Sources/Knock.swift b/Sources/Knock.swift index a6e4780..3880670 100644 --- a/Sources/Knock.swift +++ b/Sources/Knock.swift @@ -41,17 +41,17 @@ public class Knock { } } -extension Knock { +public extension Knock { // Configuration options for the Knock client SDK. - public struct KnockOptions { + struct KnockOptions { var host: String? - init(host: String? = nil) { + public init(host: String? = nil) { self.host = host } } - public enum KnockError: Error { + enum KnockError: Error { case runtimeError(String) case userIdError } From 0eddb3522484b625d4ccc962d3bfe99ff0c00959 Mon Sep 17 00:00:00 2001 From: Matt Gardner Date: Thu, 25 Jan 2024 17:00:01 -0700 Subject: [PATCH 09/22] updates --- Sources/Knock.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Knock.swift b/Sources/Knock.swift index 3880670..f28a599 100644 --- a/Sources/Knock.swift +++ b/Sources/Knock.swift @@ -28,7 +28,7 @@ public class Knock { - userToken: [optional] user token. Used in production when enhanced security is enabled - options: [optional] Options for customizing the Knock instance. */ - public init(publishableKey: String, options: KnockOptions?) { + public init(publishableKey: String, options: KnockOptions? = nil) { self.api = KnockAPI(publishableKey: publishableKey, hostname: options?.host) } From f194c6c055a5850597d9835d25480e078ba21a7c Mon Sep 17 00:00:00 2001 From: Matt Gardner Date: Mon, 29 Jan 2024 15:46:40 -0700 Subject: [PATCH 10/22] Introduce Modules and Services for independent responsibilities --- Sources/Knock.swift | 63 +++--- Sources/KnockAPI.swift | 205 ----------------- Sources/KnockAPIService.swift | 87 +++++++ Sources/KnockEnvironment.swift | 52 +++++ Sources/KnockErrors.swift | 46 ++++ Sources/Modules/Channels/ChannelModule.swift | 214 ++++++++++++++++++ Sources/Modules/Channels/ChannelService.swift | 144 +----------- Sources/Modules/Feed/FeedManager.swift | 188 +++------------ Sources/Modules/Feed/FeedModule.swift | 170 ++++++++++++++ Sources/Modules/Feed/FeedService.swift | 18 ++ .../Feed/Models/FeedClientOptions.swift | 22 +- .../Modules/Feed/Models/FeedItemScope.swift | 2 +- Sources/Modules/Messages/MessageModule.swift | 154 +++++++++++++ Sources/Modules/Messages/MessageService.swift | 56 +---- .../Preferences/PreferenceModule.swift | 72 ++++++ .../Preferences/PreferenceService.swift | 25 +- Sources/Modules/Users/UserModule.swift | 150 ++++++++++++ Sources/Modules/Users/UserService.swift | 79 +------ 18 files changed, 1070 insertions(+), 677 deletions(-) delete mode 100644 Sources/KnockAPI.swift create mode 100644 Sources/KnockAPIService.swift create mode 100644 Sources/KnockEnvironment.swift create mode 100644 Sources/KnockErrors.swift create mode 100644 Sources/Modules/Channels/ChannelModule.swift create mode 100644 Sources/Modules/Feed/FeedModule.swift create mode 100644 Sources/Modules/Feed/FeedService.swift create mode 100644 Sources/Modules/Messages/MessageModule.swift create mode 100644 Sources/Modules/Preferences/PreferenceModule.swift create mode 100644 Sources/Modules/Users/UserModule.swift diff --git a/Sources/Knock.swift b/Sources/Knock.swift index f28a599..e76fe1e 100644 --- a/Sources/Knock.swift +++ b/Sources/Knock.swift @@ -11,59 +11,48 @@ import SwiftUI public class Knock { internal static let clientVersion = "1.0.0" internal static let loggingSubsytem = "knock-swift" + + public var feedManager: FeedManager? - internal var api: KnockAPI + internal lazy var userModule = UserModule() + internal lazy var preferenceModule = PreferenceModule() + internal lazy var messageModule = MessageModule() + internal lazy var channelModule = ChannelModule() - public internal(set) var feedManager: FeedManager? - public internal(set) var userId: String? - public internal(set) var pushChannelId: String? - public internal(set) var userDeviceToken: String? - /** Returns a new instance of the Knock Client - Parameters: - publishableKey: your public API key - - userId: the user-id that will be used in the subsequent method calls - - userToken: [optional] user token. Used in production when enhanced security is enabled - options: [optional] Options for customizing the Knock instance. */ - public init(publishableKey: String, options: KnockOptions? = nil) { - self.api = KnockAPI(publishableKey: publishableKey, hostname: options?.host) + public init(publishableKey: String, hostname: String? = nil) throws { + try KnockEnvironment.shared.setPublishableKey(key: publishableKey) + KnockEnvironment.shared.baseUrl = hostname ?? "https://api.knock.app" + } + + @available(*, deprecated, message: "See v1.0.0 migration guide for more details.") + public init(publishableKey: String, userId: String, userToken: String? = nil, hostname: String? = nil) throws { + try KnockEnvironment.shared.setPublishableKey(key: publishableKey) + KnockEnvironment.shared.setUserInfo(userId: userId, userToken: userToken) + KnockEnvironment.shared.baseUrl = hostname ?? "https://api.knock.app" } internal func resetInstance() { - self.userId = nil self.feedManager = nil - self.userDeviceToken = nil - self.pushChannelId = nil - self.api.userToken = nil + KnockEnvironment.shared.resetEnvironment() } } public extension Knock { - // Configuration options for the Knock client SDK. - struct KnockOptions { - var host: String? - - public init(host: String? = nil) { - self.host = host - } - } - - enum KnockError: Error { - case runtimeError(String) - case userIdError - } + var userId: String? { + get { + return KnockEnvironment.shared.userId + } + } } -extension Knock.KnockError: LocalizedError { - public var errorDescription: String? { - switch self { - case .runtimeError(let message): - return message - case .userIdError: - return "UserId not found. Please authenticate your userId with Knock.authenticate()." - } - } -} + + +// Possibly return user in authenticate method. + diff --git a/Sources/KnockAPI.swift b/Sources/KnockAPI.swift deleted file mode 100644 index b78fdec..0000000 --- a/Sources/KnockAPI.swift +++ /dev/null @@ -1,205 +0,0 @@ -// -// Net.swift -// KnockSample -// -// Created by Diego on 26/04/23. -// - -import Foundation - -class KnockAPI { - internal let publishableKey: String - internal private(set) var host = "https://api.knock.app" - public internal(set) var userToken: String? - - private var apiBasePath: String { - "\(host)/v1" - } - - internal init(publishableKey: String, hostname: String? = nil) { - if let customHostname = hostname { - self.host = customHostname - } - self.publishableKey = publishableKey - } - - // MARK: Decode functions, they encapsulate making the request and decoding the data - - internal func decodeFromGet(_ type: T.Type, path: String, queryItems: [URLQueryItem]?, then handler: @escaping (Result) -> Void) { - get(path: path, queryItems: queryItems) { (result) in - self.decodeData(result, handler: handler) - } - } - - internal func decodeFromPost(_ type: T.Type, path: String, body: Encodable?, then handler: @escaping (Result) -> Void) { - post(path: path, body: body) { (result) in - self.decodeData(result, handler: handler) - } - } - - internal func decodeFromPut(_ type: T.Type, path: String, body: Encodable?, then handler: @escaping (Result) -> Void) { - put(path: path, body: body) { (result) in - self.decodeData(result, handler: handler) - } - } - - internal func decodeFromDelete(_ type: T.Type, path: String, body: Encodable?, then handler: @escaping (Result) -> Void) { - delete(path: path, body: body) { (result) in - self.decodeData(result, handler: handler) - } - } - - internal func decodeData(_ result: Result, handler: @escaping (Result) -> Void) { - switch result { - case .success(let data): - let decoder = JSONDecoder() - - let formatter = DateFormatter() - formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSZZZZZ" - formatter.calendar = Calendar(identifier: .iso8601) - formatter.timeZone = TimeZone(secondsFromGMT: 0) - formatter.locale = Locale(identifier: "en_US_POSIX") - - decoder.dateDecodingStrategy = .formatted(formatter) - - do { - let resultType = try decoder.decode(T.self, from: data) - DispatchQueue.main.async { - handler(.success(resultType)) - } - } - catch let error { - if let dataString = String(data: data, encoding: .utf8) { - print("error decoding data: \(dataString)") - } - else { - print("error processing undecodable data") - } - - DispatchQueue.main.async { - handler(.failure(error)) - } - } - case .failure(let error): - DispatchQueue.main.async { - handler(.failure(error)) - } - } - } - - // MARK: Method wrappers to adjust where to put the parameters (query or body) - - private func get(path: String, queryItems: [URLQueryItem]?, then handler: @escaping (Result) -> Void) { - makeGeneralRequest(method: "GET", path: path, queryItems: queryItems, body: nil, then: handler) - } - - private func post(path: String, body: Encodable?, then handler: @escaping (Result) -> Void) { - makeGeneralRequest(method: "POST", path: path, queryItems: nil, body: body, then: handler) - } - - private func put(path: String, body: Encodable?, then handler: @escaping (Result) -> Void) { - makeGeneralRequest(method: "PUT", path: path, queryItems: nil, body: body, then: handler) - } - - private func delete(path: String, body: Encodable?, then handler: @escaping (Result) -> Void) { - makeGeneralRequest(method: "DELETE", path: path, queryItems: nil, body: body, then: handler) - } - - // MARK: Actual code to make the request - - /** - Make the http request. The completion `then` handler is executed asyncronously on the main thread. - - - Attention: Currently, there's no retry policy. Here would be a good plate to implement it, for instance X number of retries and an exponential timeout on each one. Also, first check if the path or request is allowed to be retried (is it safe to retry or does it support an idempotency token?) - - - Parameters: - - method: GET, POST, PUT, DELETE - - path: the absolute path (host [+ port] + relative path) - - queryItems: optional array of `URLQueryItem` - - body: optional array of parameters to pass in the body of the request - - then: the code to execute when the response is received - */ - private func makeGeneralRequest(method: String, path: String, queryItems: [URLQueryItem]?, body: Encodable?, then handler: @escaping (Result) -> Void) { - - let sessionConfig = URLSessionConfiguration.default - let session = URLSession(configuration: sessionConfig, delegate: nil, delegateQueue: nil) - guard var URL = URL(string: "\(apiBasePath)\(path)") else {return} - - if queryItems != nil { - URL = URL.appending(queryItems: queryItems!) - } - - var request = URLRequest(url: URL) - request.httpMethod = method - - request.addValue("application/json; charset=utf-8", forHTTPHeaderField: "Content-Type") - - if body != nil { - let encoder = JSONEncoder() - let data = try! encoder.encode(body!) - request.httpBody = data - } - - // Headers - - request.addValue("knock-swift@\(Knock.clientVersion)", forHTTPHeaderField: "User-Agent") - - request.addValue("Bearer \(publishableKey)", forHTTPHeaderField: "Authorization") - if let userToken = userToken { - request.addValue(userToken, forHTTPHeaderField: "X-Knock-User-Token") - } - - // Make the request - - let task = session.dataTask(with: request, completionHandler: { (data: Data?, response: URLResponse?, error: Error?) -> Void in - if (error == nil) { - // Success - let statusCode = (response as! HTTPURLResponse).statusCode - - if let data = data { - if statusCode < 200 || statusCode > 299 { - DispatchQueue.main.async { - handler(.failure(NetworkError(title: "Status code error", description: String(data: data, encoding: .utf8) ?? "Unknown error", code: statusCode))) - } - } - - DispatchQueue.main.async { - handler(.success(data)) - } - } - else { - DispatchQueue.main.async { - handler(.failure(NetworkError(title: "Unknown Error", description: "Error, data == nil", code: statusCode))) - } - } - } - else { - DispatchQueue.main.async { - handler(.failure(error!)) - } - } - }) - task.resume() - session.finishTasksAndInvalidate() - } -} - -public protocol NetworkErrorProtocol: LocalizedError { - var title: String? { get } - var code: Int { get } -} - -public struct NetworkError: NetworkErrorProtocol { - public var title: String? - public var code: Int - public var errorDescription: String? { return _description } - public var failureReason: String? { return _description } - - private var _description: String - - public init(title: String?, description: String, code: Int) { - self.title = title ?? "Error" - self._description = description - self.code = code - } -} diff --git a/Sources/KnockAPIService.swift b/Sources/KnockAPIService.swift new file mode 100644 index 0000000..b10725e --- /dev/null +++ b/Sources/KnockAPIService.swift @@ -0,0 +1,87 @@ +// +// KnockAPIService.swift +// KnockSample +// +// Created by Matt on 01/29/2023. +// + +import Foundation + + +internal class KnockAPIService: NSObject { + + func makeRequest(method: String, path: String, queryItems: [URLQueryItem]?, body: Encodable?) async throws -> T { + + let sessionConfig = URLSessionConfiguration.default + let session = URLSession(configuration: sessionConfig, delegate: nil, delegateQueue: nil) + guard var URL = URL(string: "\(KnockEnvironment.shared.baseUrl)\(path)") else { + throw Knock.NetworkError(title: "Invalid URL", description: "The URL: \(KnockEnvironment.shared.baseUrl)\(path) is invalid", code: 0) + } + + if queryItems != nil { + URL = URL.appending(queryItems: queryItems!) + } + + var request = URLRequest(url: URL) + request.httpMethod = method + + request.addValue("application/json; charset=utf-8", forHTTPHeaderField: "Content-Type") + + if body != nil { + let encoder = JSONEncoder() + let data = try! encoder.encode(body!) + request.httpBody = data + } + + // Headers + + request.addValue("knock-swift@\(Knock.clientVersion)", forHTTPHeaderField: "User-Agent") + + request.addValue("Bearer \(KnockEnvironment.shared.publishableKey)", forHTTPHeaderField: "Authorization") + if let userToken = KnockEnvironment.shared.userToken { + request.addValue(userToken, forHTTPHeaderField: "X-Knock-User-Token") + } + + // Make the request + let (responseData, urlResponse) = try await session.data(for: request) + let statusCode = (urlResponse as! HTTPURLResponse).statusCode + if statusCode < 200 || statusCode > 299 { + throw Knock.NetworkError(title: "Status code error", description: String(data: responseData, encoding: .utf8) ?? "Unknown error", code: statusCode) + } + + return try decodeData(responseData) + } +} + +extension KnockAPIService { + internal func get(path: String, queryItems: [URLQueryItem]?) async throws -> T { + try await makeRequest(method: "GET", path: path, queryItems: queryItems, body: nil) + } + + internal func post(path: String, body: Encodable?) async throws -> T { + try await makeRequest(method: "POST", path: path, queryItems: nil, body: body) + } + + internal func put(path: String, body: Encodable?) async throws -> T { + try await makeRequest(method: "PUT", path: path, queryItems: nil, body: body) + } + + internal func delete(path: String, body: Encodable?) async throws -> T { + try await makeRequest(method: "DELETE", path: path, queryItems: nil, body: body) + } + + internal func decodeData(_ data: Data) throws -> T { + let decoder = JSONDecoder() + + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSZZZZZ" + formatter.calendar = Calendar(identifier: .iso8601) + formatter.timeZone = TimeZone(secondsFromGMT: 0) + formatter.locale = Locale(identifier: "en_US_POSIX") + + decoder.dateDecodingStrategy = .formatted(formatter) + + let result = try decoder.decode(T.self, from: data) + return result + } +} diff --git a/Sources/KnockEnvironment.swift b/Sources/KnockEnvironment.swift new file mode 100644 index 0000000..0f45b0a --- /dev/null +++ b/Sources/KnockEnvironment.swift @@ -0,0 +1,52 @@ +// +// KnockEnvironment.swift +// +// +// Created by Matt Gardner on 1/29/24. +// + +import Foundation + +internal class KnockEnvironment { + static let shared = KnockEnvironment() + + static let defaultBaseUrl: String = "https://api.knock.app" + + private(set) var userId: String? + private(set) var userToken: String? + private(set) var userDevicePushToken: String? + private(set) var pushChannelId: String? + private(set) var publishableKey: String = "" + var baseUrl: String = KnockEnvironment.defaultBaseUrl + + func setPublishableKey(key: String) throws { + guard key.hasPrefix("sk_") == false else { throw Knock.KnockError.runtimeError("[Knock] You are using your secret API key on the client. Please use the public key.") } + KnockEnvironment.shared.publishableKey = publishableKey + } + + func setPushInformation(channelId: String?, deviceToken: String?) { + self.pushChannelId = channelId + self.userDevicePushToken = deviceToken + } + + func setUserInfo(userId: String?, userToken: String?) { + self.userId = userId + self.userToken = userToken + } + + func setBaseUrl(baseUrl: String?) { + self.baseUrl = baseUrl ?? KnockEnvironment.defaultBaseUrl + } + + func resetEnvironment() { + setUserInfo(userId: nil, userToken: nil) + setPushInformation(channelId: nil, deviceToken: nil) + } + + func getSafeUserId() throws -> String { + guard let id = KnockEnvironment.shared.userId else { + throw Knock.KnockError.userIdError + } + return id + } +} diff --git a/Sources/KnockErrors.swift b/Sources/KnockErrors.swift new file mode 100644 index 0000000..13ae774 --- /dev/null +++ b/Sources/KnockErrors.swift @@ -0,0 +1,46 @@ +// +// KnockErrors.swift +// +// +// Created by Matt Gardner on 1/29/24. +// + +import Foundation + +public extension Knock { + enum KnockError: Error { + case runtimeError(String) + case userIdError + } + + struct NetworkError: NetworkErrorProtocol { + public var title: String? + public var code: Int + public var errorDescription: String? { return _description } + public var failureReason: String? { return _description } + + private var _description: String + + public init(title: String?, description: String, code: Int) { + self.title = title ?? "Error" + self._description = description + self.code = code + } + } +} + +extension Knock.KnockError: LocalizedError { + public var errorDescription: String? { + switch self { + case .runtimeError(let message): + return message + case .userIdError: + return "UserId not found. Please authenticate your userId with Knock.authenticate()." + } + } +} + +protocol NetworkErrorProtocol: LocalizedError { + var title: String? { get } + var code: Int { get } +} diff --git a/Sources/Modules/Channels/ChannelModule.swift b/Sources/Modules/Channels/ChannelModule.swift new file mode 100644 index 0000000..e20d4a6 --- /dev/null +++ b/Sources/Modules/Channels/ChannelModule.swift @@ -0,0 +1,214 @@ +// +// ChannelModule.swift +// +// +// Created by Matt Gardner on 1/26/24. +// + +import Foundation +import OSLog + +internal class ChannelModule { + let channelService = ChannelService() + private let logger: Logger = Logger(subsystem: Knock.loggingSubsytem, category: "ChannelModule") + + func getUserChannelData(channelId: String) async throws -> Knock.ChannelData { + try await channelService.getUserChannelData(channelId: channelId) + } + + func updateUserChannelData(channelId: String, data: AnyEncodable) async throws -> Knock.ChannelData { + try await channelService.updateUserChannelData(channelId: channelId, data: data) + } + + private func registerOrUpdateToken(token: String, channelId: String, existingTokens: [String]?) async throws -> Knock.ChannelData { + var tokens = existingTokens ?? [] + if !tokens.contains(token) { + tokens.append(token) + } + + let data: AnyEncodable = ["tokens": tokens] + return try await updateUserChannelData(channelId: channelId, data: data) + } + + func registerTokenForAPNS(channelId: String, token: String) async throws -> Knock.ChannelData { + do { + + let channelData = try await getUserChannelData(channelId: channelId) + guard let data = channelData.data, let tokens = data["tokens"]?.value as? [String] else { + // No valid tokens array found, register a new one + return try await registerOrUpdateToken(token: token, channelId: channelId, existingTokens: nil) + } + + if tokens.contains(token) { + // Token already registered + return channelData + } else { + // Register the new token + return try await registerOrUpdateToken(token: token, channelId: channelId, existingTokens: tokens) + } + } catch { + // No data registered on that channel for that user, we'll create a new record + return try await registerOrUpdateToken(token: token, channelId: channelId, existingTokens: nil) + } + } + + func unregisterTokenForAPNS(channelId: String, token: String) async throws -> Knock.ChannelData { + do { + let channelData = try await getUserChannelData(channelId: channelId) + guard let data = channelData.data, let tokens = data["tokens"]?.value as? [String] else { + // No valid tokens array found. + self.logger.warning("[Knock] Could not unregister user from channel \(channelId). Reason: User doesn't have any device tokens associated to the provided channelId.") + return channelData + } + + if tokens.contains(token) { + let newTokensSet = Set(tokens).subtracting([token]) + let newTokens = Array(newTokensSet) + let data: AnyEncodable = [ + "tokens": newTokens + ] + return try await updateUserChannelData(channelId: channelId, data: data) + } else { + self.logger.warning("[Knock] Could not unregister user from channel \(channelId). Reason: User doesn't have any device tokens that match the token provided.") + return channelData + } + } catch { + if let networkError = error as? Knock.NetworkError, networkError.code == 404 { + // No data registered on that channel for that user + self.logger.warning("[Knock] Could not unregister user from channel \(channelId). Reason: User doesn't have any channel data associated to the provided channelId.") + return .init(channel_id: channelId, data: [:]) + } else { + // Unknown error. Could be network or server related. Try again. + self.logger.error("[Knock] Could not unregister user from channel \(channelId). Please try again. Reason: \(error.localizedDescription)") + throw error + } + } + } +} + +public extension Knock { + + func getUserChannelData(channelId: String) async throws -> ChannelData { + try await self.channelModule.getUserChannelData(channelId: channelId) + } + + func getUserChannelData(channelId: String, completionHandler: @escaping ((Result) -> Void)) { + Task { + do { + let channelData = try await getUserChannelData(channelId: channelId) + completionHandler(.success(channelData)) + } catch { + completionHandler(.failure(error)) + } + } + } + + /** + Sets channel data for the user and the channel specified. + + - Parameters: + - channelId: the id of the channel + - data: the shape of the payload varies depending on the channel. You can learn more about channel data schemas [here](https://docs.knock.app/send-notifications/setting-channel-data#provider-data-requirements). + */ + func updateUserChannelData(channelId: String, data: AnyEncodable) async throws -> ChannelData { + try await self.channelModule.updateUserChannelData(channelId: channelId, data: data) + } + + func updateUserChannelData(channelId: String, data: AnyEncodable, completionHandler: @escaping ((Result) -> Void)) { + Task { + do { + let channelData = try await updateUserChannelData(channelId: channelId, data: data) + completionHandler(.success(channelData)) + } catch { + completionHandler(.failure(error)) + } + } + } + + // Mark: Registration of APNS device tokens + + /** + Registers an Apple Push Notification Service token so that the device can receive remote push notifications. This is a convenience method that internally gets the channel data and searches for the token. If it exists, then it's already registered and it returns. If the data does not exists or the token is missing from the array, it's added. + + You can learn more about APNS [here](https://developer.apple.com/documentation/usernotifications/registering_your_app_with_apns). + + - Attention: There's a race condition because the getting/setting of the token are not made in a transaction. + + - Parameters: + - channelId: the id of the APNS channel + - token: the APNS device token as a `String` + */ + func registerTokenForAPNS(channelId: String, token: String) async throws -> ChannelData { + return try await self.channelModule.registerTokenForAPNS(channelId: channelId, token: token) + } + + func registerTokenForAPNS(channelId: String, token: String, completionHandler: @escaping ((Result) -> Void)) { + Task { + do { + let channelData = try await registerTokenForAPNS(channelId: channelId, token: token) + completionHandler(.success(channelData)) + } catch { + completionHandler(.failure(error)) + } + } + } + + /** + Registers an Apple Push Notification Service token so that the device can receive remote push notifications. This is a convenience method that internally gets the channel data and searches for the token. If it exists, then it's already registered and it returns. If the data does not exists or the token is missing from the array, it's added. + + You can learn more about APNS [here](https://developer.apple.com/documentation/usernotifications/registering_your_app_with_apns). + + - Attention: There's a race condition because the getting/setting of the token are not made in a transaction. + + - Parameters: + - channelId: the id of the APNS channel + - token: the APNS device token as `Data` + */ + func registerTokenForAPNS(channelId: String, token: Data) async throws -> ChannelData { + // 1. Convert device token to string + let tokenString = Knock.convertTokenToString(token: token) + return try await self.channelModule.registerTokenForAPNS(channelId: channelId, token: tokenString) + } + + func registerTokenForAPNS(channelId: String, token: Data, completionHandler: @escaping ((Result) -> Void)) { + // 1. Convert device token to string + let tokenString = Knock.convertTokenToString(token: token) + registerTokenForAPNS(channelId: channelId, token: tokenString, completionHandler: completionHandler) + } + + + /** + Unregisters the current deviceId associated to the user so that the device will no longer receive remote push notifications for the provided channelId. + + - Parameters: + - channelId: the id of the APNS channel in Knock + - token: the APNS device token as a `String` + */ + + func unregisterTokenForAPNS(channelId: String, token: String) async throws -> ChannelData { + return try await self.channelModule.unregisterTokenForAPNS(channelId: channelId, token: token) + } + + func unregisterTokenForAPNS(channelId: String, token: String, completionHandler: @escaping ((Result) -> Void)) { + Task { + do { + let channelData = try await unregisterTokenForAPNS(channelId: channelId, token: token) + completionHandler(.success(channelData)) + } catch { + completionHandler(.failure(error)) + } + } + } + + func unregisterTokenForAPNS(channelId: String, token: Data) async throws -> ChannelData { + // 1. Convert device token to string + let tokenString = Knock.convertTokenToString(token: token) + return try await self.channelModule.unregisterTokenForAPNS(channelId: channelId, token: tokenString) + } + + func unregisterTokenForAPNS(channelId: String, token: Data, completionHandler: @escaping ((Result) -> Void)) { + // 1. Convert device token to string + let tokenString = Knock.convertTokenToString(token: token) + unregisterTokenForAPNS(channelId: channelId, token: tokenString, completionHandler: completionHandler) + } +} diff --git a/Sources/Modules/Channels/ChannelService.swift b/Sources/Modules/Channels/ChannelService.swift index efe98c1..181cc23 100644 --- a/Sources/Modules/Channels/ChannelService.swift +++ b/Sources/Modules/Channels/ChannelService.swift @@ -1,6 +1,6 @@ // -// File.swift -// +// ChannelService.swift +// // // Created by Diego on 30/05/23. // @@ -8,142 +8,14 @@ import Foundation import OSLog -public extension Knock { - private var logger: Logger { - Logger(subsystem: Knock.loggingSubsytem, category: "ChannelService") - } - - func getUserChannelData(channelId: String, completionHandler: @escaping ((Result) -> Void)) { - performActionWithUserId( { userId, completion in - self.api.decodeFromGet(ChannelData.self, path: "/users/\(userId)/channel_data/\(channelId)", queryItems: nil, then: completion) - }, completionHandler: completionHandler) - } - - /** - Sets channel data for the user and the channel specified. - - - Parameters: - - channelId: the id of the channel - - data: the shape of the payload varies depending on the channel. You can learn more about channel data schemas [here](https://docs.knock.app/send-notifications/setting-channel-data#provider-data-requirements). - */ - func updateUserChannelData(channelId: String, data: AnyEncodable, completionHandler: @escaping ((Result) -> Void)) { - performActionWithUserId( { userId, completion in - let payload = [ - "data": data - ] - self.api.decodeFromPut(ChannelData.self, path: "/users/\(userId)/channel_data/\(channelId)", body: payload, then: completion) - }, completionHandler: completionHandler) - } - - // Mark: Registration of APNS device tokens +internal class ChannelService: KnockAPIService { - /** - Registers an Apple Push Notification Service token so that the device can receive remote push notifications. This is a convenience method that internally gets the channel data and searches for the token. If it exists, then it's already registered and it returns. If the data does not exists or the token is missing from the array, it's added. - - You can learn more about APNS [here](https://developer.apple.com/documentation/usernotifications/registering_your_app_with_apns). - - - Attention: There's a race condition because the getting/setting of the token are not made in a transaction. - - - Parameters: - - channelId: the id of the APNS channel - - token: the APNS device token as `Data` - */ - func registerTokenForAPNS(channelId: String, token: Data, completionHandler: @escaping ((Result) -> Void)) { - // 1. Convert device token to string - let tokenString = Knock.convertTokenToString(token: token) - - registerTokenForAPNS(channelId: channelId, token: tokenString, completionHandler: completionHandler) + func getUserChannelData(channelId: String) async throws -> Knock.ChannelData { + try await get(path: "/users/\(KnockEnvironment.shared.getSafeUserId())/channel_data/\(channelId)", queryItems: nil) } - /** - Registers an Apple Push Notification Service token so that the device can receive remote push notifications. This is a convenience method that internally gets the channel data and searches for the token. If it exists, then it's already registered and it returns. If the data does not exists or the token is missing from the array, it's added. - - You can learn more about APNS [here](https://developer.apple.com/documentation/usernotifications/registering_your_app_with_apns). - - - Attention: There's a race condition because the getting/setting of the token are not made in a transaction. - - - Parameters: - - channelId: the id of the APNS channel - - token: the APNS device token as a `String` - */ - func registerTokenForAPNS(channelId: String, token: String, completionHandler: @escaping ((Result) -> Void)) { - // Closure to handle token registration/update - let registerOrUpdateToken = { [weak self] (existingTokens: [String]?) in - guard let self = self else { return } - var tokens = existingTokens ?? [] - if !tokens.contains(token) { - tokens.append(token) - } - let data: AnyEncodable = ["tokens": tokens] - self.updateUserChannelData(channelId: channelId, data: data, completionHandler: completionHandler) - } - - getUserChannelData(channelId: channelId) { result in - switch result { - case .failure(_): - // No data registered on that channel for that user, we'll create a new record - registerOrUpdateToken(nil) - completionHandler(.success(ChannelData.init(channel_id: channelId, data: [:]))) - case .success(let channelData): - guard let data = channelData.data, let tokens = data["tokens"]?.value as? [String] else { - // No valid tokens array found, register a new one - registerOrUpdateToken(nil) - return - } - - if tokens.contains(token) { - // Token already registered - completionHandler(.success(channelData)) - } else { - // Register the new token - registerOrUpdateToken(tokens) - } - } - } - } - - /** - Unregisters the current deviceId associated to the user so that the device will no longer receive remote push notifications for the provided channelId. - - - Parameters: - - channelId: the id of the APNS channel in Knock - - token: the APNS device token as a `String` - */ - - func unregisterTokenForAPNS(channelId: String, token: String, completionHandler: @escaping ((Result) -> Void)) { - getUserChannelData(channelId: channelId) { result in - switch result { - case .failure(let error): - if let networkError = error as? NetworkError, networkError.code == 404 { - // No data registered on that channel for that user - self.logger.warning("[Knock] Could not unregister user from channel \(channelId). Reason: User doesn't have any channel data associated to the provided channelId.") - completionHandler(.success(.init(channel_id: channelId, data: [:]))) - } else { - // Unknown error. Could be network or server related. Try again. - self.logger.error("[Knock] Could not unregister user from channel \(channelId). Please try again. Reason: \(error.localizedDescription)") - completionHandler(.failure(error)) - } - - case .success(let channelData): - guard let data = channelData.data, let tokens = data["tokens"]?.value as? [String] else { - // No valid tokens array found. - self.logger.warning("[Knock] Could not unregister user from channel \(channelId). Reason: User doesn't have any device tokens associated to the provided channelId.") - completionHandler(.success(channelData)) - return - } - - if tokens.contains(token) { - let newTokensSet = Set(tokens).subtracting([token]) - let newTokens = Array(newTokensSet) - let data: AnyEncodable = [ - "tokens": newTokens - ] - self.updateUserChannelData(channelId: channelId, data: data, completionHandler: completionHandler) - } else { - self.logger.warning("[Knock] Could not unregister user from channel \(channelId). Reason: User doesn't have any device tokens that match the token provided.") - completionHandler(.success(channelData)) - } - } - } + func updateUserChannelData(channelId: String, data: AnyEncodable) async throws -> Knock.ChannelData { + let body = ["data": data] + return try await put(path: "/users/\(KnockEnvironment.shared.getSafeUserId())/channel_data/\(channelId)", body: body) } } diff --git a/Sources/Modules/Feed/FeedManager.swift b/Sources/Modules/Feed/FeedManager.swift index 5e94b45..f8c0acc 100644 --- a/Sources/Modules/Feed/FeedManager.swift +++ b/Sources/Modules/Feed/FeedManager.swift @@ -10,36 +10,20 @@ import SwiftPhoenixClient import OSLog public extension Knock { - func initializeFeedManager(feedId: String, options: FeedManager.FeedClientOptions = FeedManager.FeedClientOptions(archived: .exclude)) throws { - guard let safeUserId = self.userId else { throw KnockError.userIdError } - self.feedManager = FeedManager(api: self.api, userId: safeUserId, feedId: feedId, options: options) - } - - func deInitializeFeedManager() { - self.feedManager = nil - } +// func initializeFeedManager(feedId: String, options: FeedManager.FeedClientOptions = FeedManager.FeedClientOptions(archived: .exclude)) throws { +// guard let safeUserId = self.userId else { throw KnockError.userIdError } +// self.feedManager = FeedManager(userId: safeUserId, feedId: feedId, options: options) +// } +// +// func deInitializeFeedManager() { +// self.feedManager = nil +// } class FeedManager { - private let api: KnockAPI - private let userId: String - private let socket: Socket - private var feedChannel: Channel? - private let feedId: String - private var feedTopic: String - private var defaultFeedOptions: FeedClientOptions - private let logger: Logger = Logger(subsystem: Knock.loggingSubsytem, category: "FeedManager") + private let feedModule: FeedModule - internal init(api: KnockAPI, userId: String, feedId: String, options: FeedClientOptions = FeedClientOptions(archived: .exclude)) { - // use regex and circumflex accent to mark only the starting http to be replaced and not any others - let websocketHostname = api.host.replacingOccurrences(of: "^http", with: "ws", options: .regularExpression) // default: wss://api.knock.app - let websocketPath = "\(websocketHostname)/ws/v1/websocket" // default: wss://api.knock.app/ws/v1/websocket - - self.api = api - self.userId = userId - self.socket = Socket(websocketPath, params: ["vsn": "2.0.0", "api_key": api.publishableKey, "user_token": api.userToken ?? ""]) - self.feedId = feedId - self.feedTopic = "feeds:\(feedId):\(userId)" - self.defaultFeedOptions = options + public init(feedId: String, options: FeedClientOptions = FeedClientOptions(archived: .exclude)) throws { + self.feedModule = try FeedModule(feedId: feedId, options: options) } /** @@ -49,69 +33,15 @@ public extension Knock { - options: options of type `FeedClientOptions` to merge with the default ones (set on the constructor) and scope as much as possible the results */ public func connectToFeed(options: FeedClientOptions? = nil) { - // Setup the socket to receive open/close events - socket.delegateOnOpen(to: self) { (self) in - self.logger.debug("[Knock] Socket Opened") - } - - socket.delegateOnClose(to: self) { (self) in - self.logger.debug("[Knock] Socket Closed") - } - - socket.delegateOnError(to: self) { (self, error) in - let (error, response) = error - if let statusCode = (response as? HTTPURLResponse)?.statusCode, statusCode > 400 { - self.logger.error("[Knock] Socket Errored. \(statusCode)") - self.socket.disconnect() - } else { - self.logger.error("[Knock] Socket Errored. \(error)") - } - } - - // TODO: Determine the level of logging we want from SwiftPhoenixClient. Currently this produces a lot of noise. -// socket.logger = { msg in print("LOG:", msg) } - - let mergedOptions = defaultFeedOptions.mergeOptions(options: options) - - let params = paramsFromOptions(options: mergedOptions) - - // Setup the Channel to receive and send messages - let channel = socket.channel(feedTopic, params: params) - - // Now connect the socket and join the channel - self.feedChannel = channel - self.feedChannel? - .join() - .delegateReceive("ok", to: self) { (self, _) in - self.logger.debug("[Knock] CHANNEL: \(channel.topic) joined") - } - .delegateReceive("error", to: self) { (self, message) in - self.logger.debug("[Knock] CHANNEL: \(channel.topic) failed to join. \(message.payload)") - } - - self.socket.connect() + feedModule.connectToFeed(options: options) } - public func on(eventName: String, completionHandler: @escaping ((Message) -> Void)) { - if let channel = feedChannel { - channel.delegateOn(eventName, to: self) { (self, message) in - completionHandler(message) - } - } - else { - logger.error("[Knock] Feed channel is nil. You should call first connectToFeed()") - } + public func disconnectFromFeed() { + feedModule.disconnectFromFeed() } - public func disconnectFromFeed() { - logger.debug("[Knock] Disconnecting from feed") - - if let channel = self.feedChannel { - channel.leave() - self.socket.remove(channel) - } - - self.socket.disconnect() + public func on(eventName: String, completionHandler: @escaping ((Message) -> Void)) { + feedModule.on(eventName: eventName, completionHandler: completionHandler) } /** @@ -121,25 +51,18 @@ public extension Knock { - options: options of type `FeedClientOptions` to merge with the default ones (set on the constructor) and scope as much as possible the results - completionHandler: the code to execute when the response is received */ + public func getUserFeedContent(options: FeedClientOptions? = nil) async throws -> Feed { + try await self.feedModule.getUserFeedContent(options: options) + } + public func getUserFeedContent(options: FeedClientOptions? = nil, completionHandler: @escaping ((Result) -> Void)) { - let mergedOptions = defaultFeedOptions.mergeOptions(options: options) - - let triggerDataJSON = Knock.encodeGenericDataToJSON(data: mergedOptions.trigger_data) - - let queryItems = [ - URLQueryItem(name: "page_size", value: (mergedOptions.page_size != nil) ? "\(mergedOptions.page_size!)" : nil), - URLQueryItem(name: "after", value: mergedOptions.after), - URLQueryItem(name: "before", value: mergedOptions.before), - URLQueryItem(name: "source", value: mergedOptions.source), - URLQueryItem(name: "tenant", value: mergedOptions.tenant), - URLQueryItem(name: "has_tenant", value: (mergedOptions.has_tenant != nil) ? "true" : "false"), - URLQueryItem(name: "status", value: (mergedOptions.status != nil) ? mergedOptions.status?.rawValue : ""), - URLQueryItem(name: "archived", value: (mergedOptions.archived != nil) ? mergedOptions.archived?.rawValue : ""), - URLQueryItem(name: "trigger_data", value: triggerDataJSON) - ] - - api.decodeFromGet(Feed.self, path: "/users/\(userId)/feeds/\(feedId)", queryItems: queryItems) { (result) in - completionHandler(result) + Task { + do { + let feed = try await getUserFeedContent(options: options) + completionHandler(.success(feed)) + } catch { + completionHandler(.failure(error)) + } } } @@ -153,56 +76,19 @@ public extension Knock { - options: all the options currently set on the feed to scope as much as possible the bulk update - completionHandler: the code to execute when the response is received */ - public func makeBulkStatusUpdate(type: BulkChannelMessageStatusUpdateType, options: FeedClientOptions, completionHandler: @escaping ((Result) -> Void)) { - // TODO: check https://docs.knock.app/reference#bulk-update-channel-message-status - // older_than: ISO-8601, check milliseconds - // newer_than: ISO-8601, check milliseconds - // delivery_status: one of `queued`, `sent`, `delivered`, `delivery_attempted`, `undelivered`, `not_sent` - // engagement_status: one of `seen`, `unseen`, `read`, `unread`, `archived`, `unarchived`, `interacted` - // Also check if the parameters sent here are valid - let body: AnyEncodable = [ - "user_ids": [userId], - "engagement_status": options.status != nil && options.status != .all ? options.status!.rawValue : "", - "archived": options.archived ?? "", - "has_tenant": options.has_tenant ?? "", - "tenants": (options.tenant != nil) ? [options.tenant!] : "" - ] - - api.decodeFromPost(BulkOperation.self, path: "/channels/\(feedId)/messages/bulk/\(type.rawValue)", body: body, then: completionHandler) + public func makeBulkStatusUpdate(type: BulkChannelMessageStatusUpdateType, options: FeedClientOptions) async throws -> BulkOperation { + try await feedModule.makeBulkStatusUpdate(type: type, options: options) } - private func paramsFromOptions(options: FeedClientOptions) -> [String: Any] { - var params: [String: Any] = [:] - - if let value = options.before { - params["before"] = value - } - if let value = options.after { - params["after"] = value - } - if let value = options.page_size { - params["page_size"] = value - } - if let value = options.status { - params["status"] = value.rawValue - } - if let value = options.source { - params["source"] = value - } - if let value = options.tenant { - params["tenant"] = value - } - if let value = options.has_tenant { - params["has_tenant"] = value - } - if let value = options.archived { - params["archived"] = value.rawValue - } - if let value = options.trigger_data { - params["trigger_data"] = value.dictionary() + public func makeBulkStatusUpdate(type: BulkChannelMessageStatusUpdateType, options: FeedClientOptions, completionHandler: @escaping ((Result) -> Void)) { + Task { + do { + let operation = try await makeBulkStatusUpdate(type: type, options: options) + completionHandler(.success(operation)) + } catch { + completionHandler(.failure(error)) + } } - - return params } } } diff --git a/Sources/Modules/Feed/FeedModule.swift b/Sources/Modules/Feed/FeedModule.swift new file mode 100644 index 0000000..af20208 --- /dev/null +++ b/Sources/Modules/Feed/FeedModule.swift @@ -0,0 +1,170 @@ +// +// FeedService.swift +// +// +// Created by Matt Gardner on 1/29/24. +// + +import Foundation +import SwiftPhoenixClient +import OSLog + +internal class FeedModule { + private let socket: Socket + private var feedChannel: Channel? + private let feedId: String + private var feedTopic: String + private var feedOptions: Knock.FeedClientOptions + private let logger: Logger = Logger(subsystem: Knock.loggingSubsytem, category: "FeedManager") + private let feedService = FeedService() + + internal init(feedId: String, options: Knock.FeedClientOptions) throws { + // use regex and circumflex accent to mark only the starting http to be replaced and not any others + let websocketHostname = KnockEnvironment.shared.baseUrl.replacingOccurrences(of: "^http", with: "ws", options: .regularExpression) // default: wss://api.knock.app + let websocketPath = "\(websocketHostname)/ws/v1/websocket" // default: wss://api.knock.app/ws/v1/websocket + let userId = try KnockEnvironment.shared.getSafeUserId() + self.socket = Socket(websocketPath, params: ["vsn": "2.0.0", "api_key": KnockEnvironment.shared.publishableKey, "user_token": KnockEnvironment.shared.userToken ?? ""]) + self.feedId = feedId + self.feedTopic = "feeds:\(feedId):\(userId)" + self.feedOptions = options + } + + func getUserFeedContent(options: Knock.FeedClientOptions? = nil) async throws -> Knock.Feed { + let mergedOptions = feedOptions.mergeOptions(options: options) + + let triggerDataJSON = Knock.encodeGenericDataToJSON(data: mergedOptions.trigger_data) + + let queryItems = [ + URLQueryItem(name: "page_size", value: (mergedOptions.page_size != nil) ? "\(mergedOptions.page_size!)" : nil), + URLQueryItem(name: "after", value: mergedOptions.after), + URLQueryItem(name: "before", value: mergedOptions.before), + URLQueryItem(name: "source", value: mergedOptions.source), + URLQueryItem(name: "tenant", value: mergedOptions.tenant), + URLQueryItem(name: "has_tenant", value: (mergedOptions.has_tenant != nil) ? "true" : "false"), + URLQueryItem(name: "status", value: (mergedOptions.status != nil) ? mergedOptions.status?.rawValue : ""), + URLQueryItem(name: "archived", value: (mergedOptions.archived != nil) ? mergedOptions.archived?.rawValue : ""), + URLQueryItem(name: "trigger_data", value: triggerDataJSON) + ] + + return try await feedService.getUserFeedContent(queryItems: queryItems, feedId: feedId) + } + + func makeBulkStatusUpdate(type: Knock.BulkChannelMessageStatusUpdateType, options: Knock.FeedClientOptions) async throws -> Knock.BulkOperation { + // TODO: check https://docs.knock.app/reference#bulk-update-channel-message-status + // older_than: ISO-8601, check milliseconds + // newer_than: ISO-8601, check milliseconds + // delivery_status: one of `queued`, `sent`, `delivered`, `delivery_attempted`, `undelivered`, `not_sent` + // engagement_status: one of `seen`, `unseen`, `read`, `unread`, `archived`, `unarchived`, `interacted` + // Also check if the parameters sent here are valid + let userId = try KnockEnvironment.shared.getSafeUserId() + let body: AnyEncodable = [ + "user_ids": [userId], + "engagement_status": options.status != nil && options.status != .all ? options.status!.rawValue : "", + "archived": options.archived ?? "", + "has_tenant": options.has_tenant ?? "", + "tenants": (options.tenant != nil) ? [options.tenant!] : "" + ] + return try await feedService.makeBulkStatusUpdate(feedId: feedId, type: type, body: body) + } + + func disconnectFromFeed() { + logger.debug("[Knock] Disconnecting from feed") + + if let channel = self.feedChannel { + channel.leave() + self.socket.remove(channel) + } + + self.socket.disconnect() + } + + // Todo: Make async await method for this + func on(eventName: String, completionHandler: @escaping ((Message) -> Void)) { + if let channel = feedChannel { + channel.delegateOn(eventName, to: self) { (self, message) in + completionHandler(message) + } + } + else { + logger.error("[Knock] Feed channel is nil. You should call first connectToFeed()") + } + } + + func connectToFeed(options: Knock.FeedClientOptions? = nil) { + // Setup the socket to receive open/close events + socket.delegateOnOpen(to: self) { (self) in + self.logger.debug("[Knock] Socket Opened") + } + + socket.delegateOnClose(to: self) { (self) in + self.logger.debug("[Knock] Socket Closed") + } + + socket.delegateOnError(to: self) { (self, error) in + let (error, response) = error + if let statusCode = (response as? HTTPURLResponse)?.statusCode, statusCode > 400 { + self.logger.error("[Knock] Socket Errored. \(statusCode)") + self.socket.disconnect() + } else { + self.logger.error("[Knock] Socket Errored. \(error)") + } + } + + // TODO: Determine the level of logging we want from SwiftPhoenixClient. Currently this produces a lot of noise. +// socket.logger = { msg in print("LOG:", msg) } + + let mergedOptions = feedOptions.mergeOptions(options: options) + + let params = paramsFromOptions(options: mergedOptions) + + // Setup the Channel to receive and send messages + let channel = socket.channel(feedTopic, params: params) + + // Now connect the socket and join the channel + self.feedChannel = channel + self.feedChannel? + .join() + .delegateReceive("ok", to: self) { (self, _) in + self.logger.debug("[Knock] CHANNEL: \(channel.topic) joined") + } + .delegateReceive("error", to: self) { (self, message) in + self.logger.debug("[Knock] CHANNEL: \(channel.topic) failed to join. \(message.payload)") + } + + self.socket.connect() + } + + private func paramsFromOptions(options: Knock.FeedClientOptions) -> [String: Any] { + var params: [String: Any] = [:] + + if let value = options.before { + params["before"] = value + } + if let value = options.after { + params["after"] = value + } + if let value = options.page_size { + params["page_size"] = value + } + if let value = options.status { + params["status"] = value.rawValue + } + if let value = options.source { + params["source"] = value + } + if let value = options.tenant { + params["tenant"] = value + } + if let value = options.has_tenant { + params["has_tenant"] = value + } + if let value = options.archived { + params["archived"] = value.rawValue + } + if let value = options.trigger_data { + params["trigger_data"] = value.dictionary() + } + + return params + } +} diff --git a/Sources/Modules/Feed/FeedService.swift b/Sources/Modules/Feed/FeedService.swift new file mode 100644 index 0000000..602b153 --- /dev/null +++ b/Sources/Modules/Feed/FeedService.swift @@ -0,0 +1,18 @@ +// +// FeedService.swift +// +// +// Created by Matt Gardner on 1/29/24. +// + +import Foundation + +internal class FeedService: KnockAPIService { + func getUserFeedContent(queryItems: [URLQueryItem]?, feedId: String) async throws -> Knock.Feed { + try await get(path: "/users/\(KnockEnvironment.shared.getSafeUserId())/feeds/\(feedId)", queryItems: queryItems) + } + + func makeBulkStatusUpdate(feedId: String, type: Knock.BulkChannelMessageStatusUpdateType, body: AnyEncodable?) async throws -> Knock.BulkOperation { + try await post(path: "/channels/\(feedId)/messages/bulk/\(type.rawValue)", body: body) + } +} diff --git a/Sources/Modules/Feed/Models/FeedClientOptions.swift b/Sources/Modules/Feed/Models/FeedClientOptions.swift index 353e9bd..15ac505 100644 --- a/Sources/Modules/Feed/Models/FeedClientOptions.swift +++ b/Sources/Modules/Feed/Models/FeedClientOptions.swift @@ -7,7 +7,7 @@ import Foundation -extension Knock.FeedManager { +extension Knock { public struct FeedClientOptions: Codable { public var before: String? public var after: String? @@ -20,16 +20,16 @@ extension Knock.FeedManager { public var trigger_data: [String: AnyCodable]? // GenericData public init(from decoder: Decoder) throws { - let container: KeyedDecodingContainer = try decoder.container(keyedBy: Knock.FeedManager.FeedClientOptions.CodingKeys.self) - self.before = try container.decodeIfPresent(String.self, forKey: Knock.FeedManager.FeedClientOptions.CodingKeys.before) - self.after = try container.decodeIfPresent(String.self, forKey: Knock.FeedManager.FeedClientOptions.CodingKeys.after) - self.page_size = try container.decodeIfPresent(Int.self, forKey: Knock.FeedManager.FeedClientOptions.CodingKeys.page_size) - self.status = try container.decodeIfPresent(Knock.FeedManager.FeedItemScope.self, forKey: Knock.FeedManager.FeedClientOptions.CodingKeys.status) - self.source = try container.decodeIfPresent(String.self, forKey: Knock.FeedManager.FeedClientOptions.CodingKeys.source) - self.tenant = try container.decodeIfPresent(String.self, forKey: Knock.FeedManager.FeedClientOptions.CodingKeys.tenant) - self.has_tenant = try container.decodeIfPresent(Bool.self, forKey: Knock.FeedManager.FeedClientOptions.CodingKeys.has_tenant) - self.archived = try container.decodeIfPresent(Knock.FeedManager.FeedItemArchivedScope.self, forKey: Knock.FeedManager.FeedClientOptions.CodingKeys.archived) - self.trigger_data = try container.decodeIfPresent([String : AnyCodable].self, forKey: Knock.FeedManager.FeedClientOptions.CodingKeys.trigger_data) + let container: KeyedDecodingContainer = try decoder.container(keyedBy: FeedClientOptions.CodingKeys.self) + self.before = try container.decodeIfPresent(String.self, forKey: FeedClientOptions.CodingKeys.before) + self.after = try container.decodeIfPresent(String.self, forKey: FeedClientOptions.CodingKeys.after) + self.page_size = try container.decodeIfPresent(Int.self, forKey: FeedClientOptions.CodingKeys.page_size) + self.status = try container.decodeIfPresent(Knock.FeedItemScope.self, forKey: FeedClientOptions.CodingKeys.status) + self.source = try container.decodeIfPresent(String.self, forKey: FeedClientOptions.CodingKeys.source) + self.tenant = try container.decodeIfPresent(String.self, forKey: FeedClientOptions.CodingKeys.tenant) + self.has_tenant = try container.decodeIfPresent(Bool.self, forKey: FeedClientOptions.CodingKeys.has_tenant) + self.archived = try container.decodeIfPresent(Knock.FeedItemArchivedScope.self, forKey: FeedClientOptions.CodingKeys.archived) + self.trigger_data = try container.decodeIfPresent([String : AnyCodable].self, forKey: FeedClientOptions.CodingKeys.trigger_data) } public init(before: String? = nil, after: String? = nil, page_size: Int? = nil, status: FeedItemScope? = nil, source: String? = nil, tenant: String? = nil, has_tenant: Bool? = nil, archived: FeedItemArchivedScope? = nil, trigger_data: [String : AnyCodable]? = nil) { diff --git a/Sources/Modules/Feed/Models/FeedItemScope.swift b/Sources/Modules/Feed/Models/FeedItemScope.swift index c2f98ad..e3688f6 100644 --- a/Sources/Modules/Feed/Models/FeedItemScope.swift +++ b/Sources/Modules/Feed/Models/FeedItemScope.swift @@ -8,7 +8,7 @@ import Foundation -extension Knock.FeedManager { +extension Knock { public enum FeedItemScope: String, Codable { // TODO: check engagement_status in https://docs.knock.app/reference#bulk-update-channel-message-status // extras: diff --git a/Sources/Modules/Messages/MessageModule.swift b/Sources/Modules/Messages/MessageModule.swift new file mode 100644 index 0000000..1f147d7 --- /dev/null +++ b/Sources/Modules/Messages/MessageModule.swift @@ -0,0 +1,154 @@ +// +// MessageModule.swift +// +// +// Created by Matt Gardner on 1/29/24. +// + +import Foundation + +internal class MessageModule { + let messageService = MessageService() + + internal func getMessage(messageId: String) async throws -> Knock.KnockMessage { + try await messageService.getMessage(messageId: messageId) + } + + internal func updateMessageStatus(messageId: String, status: Knock.KnockMessageStatusUpdateType) async throws -> Knock.KnockMessage { + try await messageService.updateMessageStatus(messageId: messageId, status: status) + } + + internal func deleteMessageStatus(messageId: String, status: Knock.KnockMessageStatusUpdateType) async throws -> Knock.KnockMessage { + try await messageService.deleteMessageStatus(messageId: messageId, status: status) + } + + internal func batchUpdateStatuses(messageIds: [String], status: Knock.KnockMessageStatusBatchUpdateType) async throws -> [Knock.KnockMessage] { + try await messageService.batchUpdateStatuses(messageIds: messageIds, status: status) + } +} + +public extension Knock { + + func getMessage(messageId: String) async throws -> KnockMessage { + try await self.messageModule.getMessage(messageId: messageId) + } + + func getMessage(messageId: String, completionHandler: @escaping ((Result) -> Void)) { + Task { + do { + let message = try await getMessage(messageId: messageId) + completionHandler(.success(message)) + } catch { + completionHandler(.failure(error)) + } + } + } + + func updateMessageStatus(message: KnockMessage, status: KnockMessageStatusUpdateType) async throws -> KnockMessage { + try await self.messageModule.updateMessageStatus(messageId: message.id, status: status) + } + + func updateMessageStatus(message: KnockMessage, status: KnockMessageStatusUpdateType, completionHandler: @escaping ((Result) -> Void)) { + Task { + do { + let message = try await updateMessageStatus(message: message, status: status) + completionHandler(.success(message)) + } catch { + completionHandler(.failure(error)) + } + } + } + + func updateMessageStatus(messageId: String, status: KnockMessageStatusUpdateType) async throws -> KnockMessage { + try await self.messageModule.updateMessageStatus(messageId: messageId, status: status) + } + + func updateMessageStatus(messageId: String, status: KnockMessageStatusUpdateType, completionHandler: @escaping ((Result) -> Void)) { + Task { + do { + let message = try await updateMessageStatus(messageId: messageId, status: status) + completionHandler(.success(message)) + } catch { + completionHandler(.failure(error)) + } + } + } + + func deleteMessageStatus(message: KnockMessage, status: KnockMessageStatusUpdateType) async throws -> KnockMessage { + try await self.messageModule.deleteMessageStatus(messageId: message.id, status: status) + } + + func deleteMessageStatus(message: KnockMessage, status: KnockMessageStatusUpdateType, completionHandler: @escaping ((Result) -> Void)) { + Task { + do { + let message = try await deleteMessageStatus(message: message, status: status) + completionHandler(.success(message)) + } catch { + completionHandler(.failure(error)) + } + } + } + + func deleteMessageStatus(messageId: String, status: KnockMessageStatusUpdateType) async throws -> KnockMessage { + try await self.messageModule.updateMessageStatus(messageId: messageId, status: status) + } + + func deleteMessageStatus(messageId: String, status: KnockMessageStatusUpdateType, completionHandler: @escaping ((Result) -> Void)) { + Task { + do { + let message = try await deleteMessageStatus(messageId: messageId, status: status) + completionHandler(.success(message)) + } catch { + completionHandler(.failure(error)) + } + } + } + + + /** + Batch status update for a list of messages + + - Parameters: + - messageIds: the list of message ids: `[String]` to be updated + - status: the new `Status` + - completionHandler: the code to execute when the response is received + */ + func batchUpdateStatuses(messageIds: [String], status: KnockMessageStatusBatchUpdateType) async throws -> [KnockMessage] { + try await self.messageModule.batchUpdateStatuses(messageIds: messageIds, status: status) + } + + func batchUpdateStatuses(messageIds: [String], status: KnockMessageStatusBatchUpdateType, completionHandler: @escaping ((Result<[KnockMessage], Error>) -> Void)) { + Task { + do { + let messages = try await batchUpdateStatuses(messageIds: messageIds, status: status) + completionHandler(.success(messages)) + } catch { + completionHandler(.failure(error)) + } + } + } + + /** + Batch status update for a list of messages + + - Parameters: + - messages: the list of messages `[KnockMessage]` to be updated + - status: the new `Status` + - completionHandler: the code to execute when the response is received + */ + func batchUpdateStatuses(messages: [KnockMessage], status: KnockMessageStatusBatchUpdateType) async throws -> [KnockMessage] { + let messageIds = messages.map{$0.id} + return try await self.messageModule.batchUpdateStatuses(messageIds: messageIds, status: status) + } + + func batchUpdateStatuses(messages: [KnockMessage], status: KnockMessageStatusBatchUpdateType, completionHandler: @escaping ((Result<[KnockMessage], Error>) -> Void)) { + Task { + do { + let messages = try await batchUpdateStatuses(messages: messages, status: status) + completionHandler(.success(messages)) + } catch { + completionHandler(.failure(error)) + } + } + } +} diff --git a/Sources/Modules/Messages/MessageService.swift b/Sources/Modules/Messages/MessageService.swift index 56a863e..cbeecab 100644 --- a/Sources/Modules/Messages/MessageService.swift +++ b/Sources/Modules/Messages/MessageService.swift @@ -7,58 +7,22 @@ import Foundation -public extension Knock { +internal class MessageService: KnockAPIService { - func getMessage(messageId: String, completionHandler: @escaping ((Result) -> Void)) { - self.api.decodeFromGet(KnockMessage.self, path: "/messages/\(messageId)", queryItems: nil, then: completionHandler) + internal func getMessage(messageId: String) async throws -> Knock.KnockMessage { + try await get(path: "/messages/\(messageId)", queryItems: nil) } - func updateMessageStatus(message: KnockMessage, status: KnockMessageStatusUpdateType, completionHandler: @escaping ((Result) -> Void)) { - updateMessageStatus(messageId: message.id, status: status, completionHandler: completionHandler) + internal func updateMessageStatus(messageId: String, status: Knock.KnockMessageStatusUpdateType) async throws -> Knock.KnockMessage { + try await put(path: "/messages/\(messageId)/\(status.rawValue)", body: nil) } - func updateMessageStatus(messageId: String, status: KnockMessageStatusUpdateType, completionHandler: @escaping ((Result) -> Void)) { - self.api.decodeFromPut(KnockMessage.self, path: "/messages/\(messageId)/\(status.rawValue)", body: nil, then: completionHandler) + internal func deleteMessageStatus(messageId: String, status: Knock.KnockMessageStatusUpdateType) async throws -> Knock.KnockMessage { + try await delete(path: "/messages/\(messageId)/\(status.rawValue)", body: nil) } - func deleteMessageStatus(message: KnockMessage, status: KnockMessageStatusUpdateType, completionHandler: @escaping ((Result) -> Void)) { - deleteMessageStatus(messageId: message.id, status: status, completionHandler: completionHandler) - } - - func deleteMessageStatus(messageId: String, status: KnockMessageStatusUpdateType, completionHandler: @escaping ((Result) -> Void)) { - self.api.decodeFromDelete(KnockMessage.self, path: "/messages/\(messageId)/\(status.rawValue)", body: nil, then: completionHandler) - } - - /** - Batch status update for a list of messages - - - Parameters: - - messageIds: the list of message ids: `[String]` to be updated - - status: the new `Status` - - completionHandler: the code to execute when the response is received - */ - func batchUpdateStatuses(messageIds: [String], status: KnockMessageStatusBatchUpdateType, completionHandler: @escaping ((Result<[KnockMessage], Error>) -> Void)) { - let body = [ - "message_ids": messageIds - ] - - api.decodeFromPost([KnockMessage].self, path: "/messages/batch/\(status.rawValue)", body: body, then: completionHandler) - } - - /** - Batch status update for a list of messages - - - Parameters: - - messages: the list of messages `[KnockMessage]` to be updated - - status: the new `Status` - - completionHandler: the code to execute when the response is received - */ - func batchUpdateStatuses(messages: [KnockMessage], status: KnockMessageStatusBatchUpdateType, completionHandler: @escaping ((Result<[KnockMessage], Error>) -> Void)) { - let messageIds = messages.map{$0.id} - let body = [ - "message_ids": messageIds - ] - - api.decodeFromPost([KnockMessage].self, path: "/messages/batch/\(status.rawValue)", body: body, then: completionHandler) + internal func batchUpdateStatuses(messageIds: [String], status: Knock.KnockMessageStatusBatchUpdateType) async throws -> [Knock.KnockMessage] { + let body = ["message_ids": messageIds] + return try await post(path: "/messages/batch/\(status.rawValue)", body: body) } } diff --git a/Sources/Modules/Preferences/PreferenceModule.swift b/Sources/Modules/Preferences/PreferenceModule.swift new file mode 100644 index 0000000..4e08dc2 --- /dev/null +++ b/Sources/Modules/Preferences/PreferenceModule.swift @@ -0,0 +1,72 @@ +// +// PreferenceModule.swift +// +// +// Created by Matt Gardner on 1/29/24. +// + +import Foundation +import OSLog + +internal class PreferenceModule { + let preferenceService = PreferenceService() + + internal func getAllUserPreferences() async throws -> [Knock.PreferenceSet] { + try await preferenceService.getAllUserPreferences() + } + + internal func getUserPreferences(preferenceId: String) async throws -> Knock.PreferenceSet { + try await preferenceService.getUserPreferences(preferenceId: preferenceId) + } + + internal func setUserPreferences(preferenceId: String, preferenceSet: Knock.PreferenceSet) async throws -> Knock.PreferenceSet { + try await preferenceService.setUserPreferences(preferenceId: preferenceId, preferenceSet: preferenceSet) + } +} + +public extension Knock { + func getAllUserPreferences() async throws -> [Knock.PreferenceSet] { + try await self.preferenceModule.getAllUserPreferences() + } + + func getAllUserPreferences(completionHandler: @escaping ((Result<[PreferenceSet], Error>) -> Void)) { + Task { + do { + let preferences = try await getAllUserPreferences() + completionHandler(.success(preferences)) + } catch { + completionHandler(.failure(error)) + } + } + } + + func getUserPreferences(preferenceId: String) async throws -> Knock.PreferenceSet { + try await self.preferenceModule.getUserPreferences(preferenceId: preferenceId) + } + + func getUserPreferences(preferenceId: String, completionHandler: @escaping ((Result) -> Void)) { + Task { + do { + let preferences = try await getUserPreferences(preferenceId: preferenceId) + completionHandler(.success(preferences)) + } catch { + completionHandler(.failure(error)) + } + } + } + + func setUserPreferences(preferenceId: String, preferenceSet: PreferenceSet) async throws -> Knock.PreferenceSet { + try await self.preferenceModule.setUserPreferences(preferenceId: preferenceId, preferenceSet: preferenceSet) + } + + func setUserPreferences(preferenceId: String, preferenceSet: PreferenceSet, completionHandler: @escaping ((Result) -> Void)) { + Task { + do { + let preferences = try await setUserPreferences(preferenceId: preferenceId, preferenceSet: preferenceSet) + completionHandler(.success(preferences)) + } catch { + completionHandler(.failure(error)) + } + } + } +} diff --git a/Sources/Modules/Preferences/PreferenceService.swift b/Sources/Modules/Preferences/PreferenceService.swift index f2c431a..5a18e8b 100644 --- a/Sources/Modules/Preferences/PreferenceService.swift +++ b/Sources/Modules/Preferences/PreferenceService.swift @@ -1,5 +1,5 @@ // -// File.swift +// PreferenceService.swift // // // Created by Matt Gardner on 1/19/24. @@ -7,24 +7,17 @@ import Foundation -public extension Knock { - - func getAllUserPreferences(completionHandler: @escaping ((Result<[PreferenceSet], Error>) -> Void)) { - performActionWithUserId( { userId, completion in - self.api.decodeFromGet([PreferenceSet].self, path: "/users/\(userId)/preferences", queryItems: nil, then: completion) - }, completionHandler: completionHandler) +internal class PreferenceService: KnockAPIService { + + internal func getAllUserPreferences() async throws -> [Knock.PreferenceSet] { + try await get(path: "/users/\(KnockEnvironment.shared.getSafeUserId())/preferences", queryItems: nil) } - func getUserPreferences(preferenceId: String, completionHandler: @escaping ((Result) -> Void)) { - performActionWithUserId( { userId, completion in - self.api.decodeFromGet(PreferenceSet.self, path: "/users/\(userId)/preferences/\(preferenceId)", queryItems: nil, then: completion) - }, completionHandler: completionHandler) + internal func getUserPreferences(preferenceId: String) async throws -> Knock.PreferenceSet { + try await get(path: "/users/\(KnockEnvironment.shared.getSafeUserId())/preferences/\(preferenceId)", queryItems: nil) } - func setUserPreferences(preferenceId: String, preferenceSet: PreferenceSet, completionHandler: @escaping ((Result) -> Void)) { - performActionWithUserId( { userId, completion in - let payload = preferenceSet - self.api.decodeFromPut(PreferenceSet.self, path: "/users/\(userId)/preferences/\(preferenceId)", body: payload, then: completion) - }, completionHandler: completionHandler) + internal func setUserPreferences(preferenceId: String, preferenceSet: Knock.PreferenceSet) async throws -> Knock.PreferenceSet { + try await put(path: "/users/\(KnockEnvironment.shared.getSafeUserId())/preferences/\(preferenceId)", body: preferenceSet) } } diff --git a/Sources/Modules/Users/UserModule.swift b/Sources/Modules/Users/UserModule.swift new file mode 100644 index 0000000..4371334 --- /dev/null +++ b/Sources/Modules/Users/UserModule.swift @@ -0,0 +1,150 @@ +// +// File.swift +// +// +// Created by Matt Gardner on 1/26/24. +// + +import Foundation +import OSLog + +internal class UserModule { + let userService = UserService() + private let logger: Logger = Logger(subsystem: Knock.loggingSubsytem, category: "User") + + func getUser() async throws -> Knock.User { + return try await userService.getUser() + } + + func updateUser(user: Knock.User) async throws -> Knock.User { + return try await userService.updateUser(user: user) + } +} + +public extension Knock { + + func getUser() async throws -> User { + return try await userModule.getUser() + } + + func getUser(completionHandler: @escaping ((Result) -> Void)) { + Task { + do { + let user = try await getUser() + completionHandler(.success(user)) + } catch { + completionHandler(.failure(error)) + } + } + } + + func updateUser(user: User) async throws -> User { + return try await userModule.updateUser(user: user) + } + + func updateUser(user: User, completionHandler: @escaping ((Result) -> Void)) { + Task { + do { + let user = try await updateUser(user: user) + completionHandler(.success(user)) + } catch { + completionHandler(.failure(error)) + } + } + } +} + + + +internal class AuthenticationModule { + let userService = UserService() + let channelService = ChannelService() + + private let logger: Logger = Logger(subsystem: Knock.loggingSubsytem, category: "Authentication") + + // AuthenticateUser + // logoutUser + + +// func authenticateUser(userId: String, userToken: String?) async throws { +// // TODO: remove previous userID and token. +// +// KnockEnvironment.shared.userId = userId +// KnockEnvironment.shared.userToken = userToken +// } + +// func authenticate(userId: String, userToken: String? = nil) { +// self.userId = userId +// self.api.userToken = userToken +// if let token = deviceToken, let channelId = pushChannelId { +// self.registerTokenForAPNS(channelId: channelId, token: token) { result in +// switch result { +// case .success(_): +// self.pushChannelId = pushChannelId +// self.userDeviceToken = deviceToken +// self.logger.debug("success registering the push token with Knock") +// completion(.success(())) +// case .failure(let error): +// self.logger.error("Error in registerTokenForAPNS: \(error.localizedDescription)") +// completion(.failure(error)) +// } +// } +// } +// } +} + +public extension Knock { + + func isAuthenticated(checkUserToken: Bool = false) -> Bool { + let isUser = KnockEnvironment.shared.userId?.isEmpty == false + if checkUserToken { + return isUser && KnockEnvironment.shared.userToken?.isEmpty == false + } + return isUser + } + + /** + Set the current credentials for the user and their access token + You should consider using this in areas where you update your local user's state + */ + func signIn(userId: String, userToken: String?) async throws { + KnockEnvironment.shared.setUserInfo(userId: userId, userToken: userToken) + } + + func signIn(userId: String, userToken: String?, completionHandler: @escaping ((Result) -> Void)) { + Task { + do { + try await signIn(userId: userId, userToken: userToken) + completionHandler(.success(())) + } catch { + completionHandler(.failure(error)) + } + } + } + + /** + Clears the current user id and access token + You should call this when your user signs out + It will remove the current tokens used for this user in Courier so they do not receive pushes they should not get + */ + func signOut() async throws { + guard let channelId = KnockEnvironment.shared.pushChannelId, let token = KnockEnvironment.shared.userDevicePushToken else { + self.resetInstance() + return + } + let _ = try await self.unregisterTokenForAPNS(channelId: channelId, token: token) + self.resetInstance() + return + } + + func signOut(completionHandler: @escaping ((Result) -> Void)) { + Task { + do { + try await signOut() + completionHandler(.success(())) + } catch { + completionHandler(.failure(error)) + } + } + } +} diff --git a/Sources/Modules/Users/UserService.swift b/Sources/Modules/Users/UserService.swift index 6889217..a541ef0 100644 --- a/Sources/Modules/Users/UserService.swift +++ b/Sources/Modules/Users/UserService.swift @@ -6,83 +6,14 @@ // import Foundation -import OSLog -public extension Knock { +internal class UserService: KnockAPIService { - private var logger: Logger { - Logger(subsystem: Knock.loggingSubsytem, category: "UserService") + internal func getUser() async throws -> Knock.User { + try await get(path: "/users/\(KnockEnvironment.shared.getSafeUserId())", queryItems: nil) } - /** - Sets the user for the current Knock instance. Will also register the device for push notifications if token and channelId are provided. - You can also register the device for push notifications later on by calling Knock.registerTokenForAPNS() - - - Parameters: - - userId: the user-id that will be used in the subsequent method calls - - userToken: [optional] user token. Used in production when enhanced security is enabled - - deviceToken: [optional] Options for customizing the Knock instance. - - pushChannelId: [optional] Options for customizing the Knock instance. - */ - func authenticate(userId: String, userToken: String? = nil, deviceToken: String? = nil, pushChannelId: String? = nil, completion: @escaping (Result) -> Void) { - self.userId = userId - self.pushChannelId = pushChannelId - self.userDeviceToken = deviceToken - self.api.userToken = userToken - if let token = deviceToken, let channelId = pushChannelId { - self.registerTokenForAPNS(channelId: channelId, token: token) { result in - switch result { - case .success(_): - self.pushChannelId = pushChannelId - self.userDeviceToken = deviceToken - self.logger.debug("success registering the push token with Knock") - completion(.success(())) - case .failure(let error): - self.logger.error("Error in registerTokenForAPNS: \(error.localizedDescription)") - completion(.failure(error)) - } - } - } - } - - func isAuthenticated(checkUserToken: Bool = false) -> Bool { - if checkUserToken { - return self.userId?.isEmpty == false && self.api.userToken?.isEmpty == false - } - return self.userId?.isEmpty == false - } - - /** - Sets the user for the current Knock instance. Will also register the device for push notifications if token and channelId are provided. - You can also register the device for push notifications later on by calling Knock.registerTokenForAPNS() - */ - func logout(completion: @escaping (Result) -> Void) { - guard let channelId = self.pushChannelId, let token = self.userDeviceToken else { - self.resetInstance() - completion(.success(())) - return - } - self.unregisterTokenForAPNS(channelId: channelId, token: token) { result in - switch result { - case .success(_): - self.resetInstance() - completion(.success(())) - case .failure(let error): - // Don't reset data if there was an error in the unregistration step. That way the client can retry the logout if they want. - completion(.failure(error)) - } - } - } - - func getUser(completionHandler: @escaping ((Result) -> Void)) { - performActionWithUserId( { userId, completion in - self.api.decodeFromGet(User.self, path: "/users/\(userId)", queryItems: nil, then: completion) - }, completionHandler: completionHandler) - } - - func updateUser(user: User, completionHandler: @escaping ((Result) -> Void)) { - performActionWithUserId( { userId, completion in - self.api.decodeFromPut(User.self, path: "/users/\(userId)", body: user, then: completion) - }, completionHandler: completionHandler) + internal func updateUser(user: Knock.User) async throws -> Knock.User { + try await put(path: "/users/\(KnockEnvironment.shared.getSafeUserId())", body: user) } } From 656fef97e53eee2c7dbd5f593dae4e8135b3c9d4 Mon Sep 17 00:00:00 2001 From: Matt Gardner Date: Mon, 29 Jan 2024 16:26:56 -0700 Subject: [PATCH 11/22] Authentication --- Sources/KnockErrors.swift | 2 +- Sources/Modules/Channels/ChannelModule.swift | 8 ++++++-- Sources/Modules/Feed/FeedManager.swift | 10 +--------- Sources/Modules/Feed/FeedModule.swift | 2 +- Sources/Modules/Feed/Models/Block.swift | 2 +- Sources/Modules/Feed/Models/BulkOperation.swift | 2 +- Sources/Modules/Feed/Models/Feed.swift | 2 +- Sources/Modules/Users/UserModule.swift | 10 +++++++++- 8 files changed, 21 insertions(+), 17 deletions(-) diff --git a/Sources/KnockErrors.swift b/Sources/KnockErrors.swift index 13ae774..4f25e2c 100644 --- a/Sources/KnockErrors.swift +++ b/Sources/KnockErrors.swift @@ -8,7 +8,7 @@ import Foundation public extension Knock { - enum KnockError: Error { + enum KnockError: Error, Equatable { case runtimeError(String) case userIdError } diff --git a/Sources/Modules/Channels/ChannelModule.swift b/Sources/Modules/Channels/ChannelModule.swift index e20d4a6..34d9d39 100644 --- a/Sources/Modules/Channels/ChannelModule.swift +++ b/Sources/Modules/Channels/ChannelModule.swift @@ -10,7 +10,7 @@ import OSLog internal class ChannelModule { let channelService = ChannelService() - private let logger: Logger = Logger(subsystem: Knock.loggingSubsytem, category: "ChannelModule") + private let logger: Logger = Logger(subsystem: Knock.loggingSubsytem, category: "Channels") func getUserChannelData(channelId: String) async throws -> Knock.ChannelData { try await channelService.getUserChannelData(channelId: channelId) @@ -31,8 +31,9 @@ internal class ChannelModule { } func registerTokenForAPNS(channelId: String, token: String) async throws -> Knock.ChannelData { + KnockEnvironment.shared.setPushInformation(channelId: channelId, deviceToken: token) + do { - let channelData = try await getUserChannelData(channelId: channelId) guard let data = channelData.data, let tokens = data["tokens"]?.value as? [String] else { // No valid tokens array found, register a new one @@ -46,6 +47,9 @@ internal class ChannelModule { // Register the new token return try await registerOrUpdateToken(token: token, channelId: channelId, existingTokens: tokens) } + } catch let userIdError as Knock.KnockError where userIdError == Knock.KnockError.userIdError { + logger.warning("[Knock] ChannelId and deviceToken were saved. However, we cannot register for APNS until you have have called Knock.signIn().") + throw userIdError } catch { // No data registered on that channel for that user, we'll create a new record return try await registerOrUpdateToken(token: token, channelId: channelId, existingTokens: nil) diff --git a/Sources/Modules/Feed/FeedManager.swift b/Sources/Modules/Feed/FeedManager.swift index f8c0acc..364622c 100644 --- a/Sources/Modules/Feed/FeedManager.swift +++ b/Sources/Modules/Feed/FeedManager.swift @@ -10,15 +10,7 @@ import SwiftPhoenixClient import OSLog public extension Knock { -// func initializeFeedManager(feedId: String, options: FeedManager.FeedClientOptions = FeedManager.FeedClientOptions(archived: .exclude)) throws { -// guard let safeUserId = self.userId else { throw KnockError.userIdError } -// self.feedManager = FeedManager(userId: safeUserId, feedId: feedId, options: options) -// } -// -// func deInitializeFeedManager() { -// self.feedManager = nil -// } - + class FeedManager { private let feedModule: FeedModule diff --git a/Sources/Modules/Feed/FeedModule.swift b/Sources/Modules/Feed/FeedModule.swift index af20208..21424b4 100644 --- a/Sources/Modules/Feed/FeedModule.swift +++ b/Sources/Modules/Feed/FeedModule.swift @@ -15,7 +15,7 @@ internal class FeedModule { private let feedId: String private var feedTopic: String private var feedOptions: Knock.FeedClientOptions - private let logger: Logger = Logger(subsystem: Knock.loggingSubsytem, category: "FeedManager") + private let logger: Logger = Logger(subsystem: Knock.loggingSubsytem, category: "Feed") private let feedService = FeedService() internal init(feedId: String, options: Knock.FeedClientOptions) throws { diff --git a/Sources/Modules/Feed/Models/Block.swift b/Sources/Modules/Feed/Models/Block.swift index dfc9c37..28c3d28 100644 --- a/Sources/Modules/Feed/Models/Block.swift +++ b/Sources/Modules/Feed/Models/Block.swift @@ -1,5 +1,5 @@ // -// File.swift +// Block.swift // // // Created by Matt Gardner on 1/18/24. diff --git a/Sources/Modules/Feed/Models/BulkOperation.swift b/Sources/Modules/Feed/Models/BulkOperation.swift index aadedf7..6487ce8 100644 --- a/Sources/Modules/Feed/Models/BulkOperation.swift +++ b/Sources/Modules/Feed/Models/BulkOperation.swift @@ -1,5 +1,5 @@ // -// File.swift +// BulkOperation.swift // // // Created by Matt Gardner on 1/19/24. diff --git a/Sources/Modules/Feed/Models/Feed.swift b/Sources/Modules/Feed/Models/Feed.swift index 121bd90..ff957b8 100644 --- a/Sources/Modules/Feed/Models/Feed.swift +++ b/Sources/Modules/Feed/Models/Feed.swift @@ -1,5 +1,5 @@ // -// File.swift +// Feed.swift // // // Created by Matt Gardner on 1/19/24. diff --git a/Sources/Modules/Users/UserModule.swift b/Sources/Modules/Users/UserModule.swift index 4371334..9c2c2be 100644 --- a/Sources/Modules/Users/UserModule.swift +++ b/Sources/Modules/Users/UserModule.swift @@ -104,11 +104,19 @@ public extension Knock { } /** - Set the current credentials for the user and their access token + Set the current credentials for the user and their access token. + Will also registerAPNS device token if set previously. You should consider using this in areas where you update your local user's state */ func signIn(userId: String, userToken: String?) async throws { KnockEnvironment.shared.setUserInfo(userId: userId, userToken: userToken) + + if let token = KnockEnvironment.shared.userDevicePushToken, let channelId = KnockEnvironment.shared.pushChannelId { + let _ = try await registerTokenForAPNS(channelId: channelId, token: token) + return + } + + return } func signIn(userId: String, userToken: String?, completionHandler: @escaping ((Result) -> Void)) { From 92802d1ecf72af0c83c6d1933e5a6fe5e83fcf11 Mon Sep 17 00:00:00 2001 From: Matt Gardner Date: Mon, 29 Jan 2024 16:51:50 -0700 Subject: [PATCH 12/22] fixes --- Sources/Knock.swift | 8 ++++---- Sources/KnockEnvironment.swift | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Sources/Knock.swift b/Sources/Knock.swift index e76fe1e..4fcc89f 100644 --- a/Sources/Knock.swift +++ b/Sources/Knock.swift @@ -26,14 +26,14 @@ public class Knock { - publishableKey: your public API key - options: [optional] Options for customizing the Knock instance. */ - public init(publishableKey: String, hostname: String? = nil) throws { - try KnockEnvironment.shared.setPublishableKey(key: publishableKey) + public init(publishableKey: String, hostname: String? = nil) { + KnockEnvironment.shared.setPublishableKey(key: publishableKey) KnockEnvironment.shared.baseUrl = hostname ?? "https://api.knock.app" } @available(*, deprecated, message: "See v1.0.0 migration guide for more details.") - public init(publishableKey: String, userId: String, userToken: String? = nil, hostname: String? = nil) throws { - try KnockEnvironment.shared.setPublishableKey(key: publishableKey) + public init(publishableKey: String, userId: String, userToken: String? = nil, hostname: String? = nil) { + KnockEnvironment.shared.setPublishableKey(key: publishableKey) KnockEnvironment.shared.setUserInfo(userId: userId, userToken: userToken) KnockEnvironment.shared.baseUrl = hostname ?? "https://api.knock.app" } diff --git a/Sources/KnockEnvironment.swift b/Sources/KnockEnvironment.swift index 0f45b0a..843ae7c 100644 --- a/Sources/KnockEnvironment.swift +++ b/Sources/KnockEnvironment.swift @@ -19,9 +19,9 @@ internal class KnockEnvironment { private(set) var publishableKey: String = "" var baseUrl: String = KnockEnvironment.defaultBaseUrl - func setPublishableKey(key: String) throws { - guard key.hasPrefix("sk_") == false else { throw Knock.KnockError.runtimeError("[Knock] You are using your secret API key on the client. Please use the public key.") } - KnockEnvironment.shared.publishableKey = publishableKey + func setPublishableKey(key: String) { + guard key.hasPrefix("sk_") == false else { fatalError("[Knock] You are using your secret API key on the client. Please use the public key.") } + self.publishableKey = key } func setPushInformation(channelId: String?, deviceToken: String?) { From 41c52943e34fb7879ebdfb56068b2bff134bb179 Mon Sep 17 00:00:00 2001 From: Matt Gardner Date: Mon, 29 Jan 2024 17:03:31 -0700 Subject: [PATCH 13/22] set version on api base path --- Sources/KnockEnvironment.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/KnockEnvironment.swift b/Sources/KnockEnvironment.swift index 843ae7c..3d72948 100644 --- a/Sources/KnockEnvironment.swift +++ b/Sources/KnockEnvironment.swift @@ -35,7 +35,7 @@ internal class KnockEnvironment { } func setBaseUrl(baseUrl: String?) { - self.baseUrl = baseUrl ?? KnockEnvironment.defaultBaseUrl + self.baseUrl = "\(baseUrl ?? KnockEnvironment.defaultBaseUrl)/v1" } func resetEnvironment() { From 941b93c3cc33915a7442a58ffd99d26c1e11adc9 Mon Sep 17 00:00:00 2001 From: Matt Gardner Date: Mon, 29 Jan 2024 17:11:32 -0700 Subject: [PATCH 14/22] Fixes --- Sources/Knock.swift | 2 +- Sources/KnockEnvironment.swift | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Sources/Knock.swift b/Sources/Knock.swift index 4fcc89f..c028717 100644 --- a/Sources/Knock.swift +++ b/Sources/Knock.swift @@ -28,7 +28,7 @@ public class Knock { */ public init(publishableKey: String, hostname: String? = nil) { KnockEnvironment.shared.setPublishableKey(key: publishableKey) - KnockEnvironment.shared.baseUrl = hostname ?? "https://api.knock.app" + KnockEnvironment.shared.setBaseUrl(baseUrl: hostname) } @available(*, deprecated, message: "See v1.0.0 migration guide for more details.") diff --git a/Sources/KnockEnvironment.swift b/Sources/KnockEnvironment.swift index 3d72948..d1e5dd1 100644 --- a/Sources/KnockEnvironment.swift +++ b/Sources/KnockEnvironment.swift @@ -17,7 +17,7 @@ internal class KnockEnvironment { private(set) var userDevicePushToken: String? private(set) var pushChannelId: String? private(set) var publishableKey: String = "" - var baseUrl: String = KnockEnvironment.defaultBaseUrl + private(set) var baseUrl: String = "" func setPublishableKey(key: String) { guard key.hasPrefix("sk_") == false else { fatalError("[Knock] You are using your secret API key on the client. Please use the public key.") } @@ -35,7 +35,7 @@ internal class KnockEnvironment { } func setBaseUrl(baseUrl: String?) { - self.baseUrl = "\(baseUrl ?? KnockEnvironment.defaultBaseUrl)/v1" + self.baseUrl = "\(baseUrl ?? KnockEnvironment.defaultBaseUrl)/v1)" } func resetEnvironment() { From 705d545e9871ec790a29fb3f31eb2322dcc17670 Mon Sep 17 00:00:00 2001 From: Matt Gardner Date: Mon, 29 Jan 2024 17:12:18 -0700 Subject: [PATCH 15/22] fix --- Sources/Knock.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Knock.swift b/Sources/Knock.swift index c028717..0d6e539 100644 --- a/Sources/Knock.swift +++ b/Sources/Knock.swift @@ -35,7 +35,7 @@ public class Knock { public init(publishableKey: String, userId: String, userToken: String? = nil, hostname: String? = nil) { KnockEnvironment.shared.setPublishableKey(key: publishableKey) KnockEnvironment.shared.setUserInfo(userId: userId, userToken: userToken) - KnockEnvironment.shared.baseUrl = hostname ?? "https://api.knock.app" + KnockEnvironment.shared.setBaseUrl(baseUrl: hostname) } internal func resetInstance() { From 8429e8304ef92fd1da8f0e7b7fff75376c162e33 Mon Sep 17 00:00:00 2001 From: Matt Gardner Date: Mon, 29 Jan 2024 17:15:31 -0700 Subject: [PATCH 16/22] implementation fix --- Sources/KnockEnvironment.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/KnockEnvironment.swift b/Sources/KnockEnvironment.swift index d1e5dd1..f5f3ef6 100644 --- a/Sources/KnockEnvironment.swift +++ b/Sources/KnockEnvironment.swift @@ -35,7 +35,7 @@ internal class KnockEnvironment { } func setBaseUrl(baseUrl: String?) { - self.baseUrl = "\(baseUrl ?? KnockEnvironment.defaultBaseUrl)/v1)" + self.baseUrl = "\(baseUrl ?? KnockEnvironment.defaultBaseUrl)/v1" } func resetEnvironment() { From ecbb0e69d8ed47f5234b5e7ac28006a9719bbb47 Mon Sep 17 00:00:00 2001 From: Matt Gardner Date: Tue, 30 Jan 2024 14:51:57 -0700 Subject: [PATCH 17/22] Updated logging --- Sources/Knock.swift | 7 +- Sources/KnockAPIService.swift | 38 ++++++++-- Sources/KnockEnvironment.swift | 2 +- Sources/KnockLogger.swift | 71 +++++++++++++++++++ Sources/Modules/Channels/ChannelModule.swift | 37 +++++++--- Sources/Modules/Feed/FeedModule.swift | 49 +++++++++---- Sources/Modules/Messages/MessageModule.swift | 36 ++++++++-- .../Preferences/PreferenceModule.swift | 28 +++++++- Sources/Modules/Users/UserModule.swift | 19 ++++- 9 files changed, 244 insertions(+), 43 deletions(-) create mode 100644 Sources/KnockLogger.swift diff --git a/Sources/Knock.swift b/Sources/Knock.swift index 0d6e539..b4803f4 100644 --- a/Sources/Knock.swift +++ b/Sources/Knock.swift @@ -6,6 +6,7 @@ // import SwiftUI +import OSLog // Knock client SDK. public class Knock { @@ -54,5 +55,7 @@ public extension Knock { -// Possibly return user in authenticate method. - +// TODO: Possibly return user in authenticate method. +// TODO: Ensure threads are correct +// TODO: Handle AppDelegate +// TODO: Evaluate classes and determine if they should be structs diff --git a/Sources/KnockAPIService.swift b/Sources/KnockAPIService.swift index b10725e..62d6583 100644 --- a/Sources/KnockAPIService.swift +++ b/Sources/KnockAPIService.swift @@ -6,16 +6,25 @@ // import Foundation - +import OSLog internal class KnockAPIService: NSObject { + private let logger: Logger = Logger(subsystem: Knock.loggingSubsytem, category: "Network") + private let apiVersion = "v1" + private var apiBaseUrl: String { + return "\(KnockEnvironment.shared.baseUrl)/v1" + } + func makeRequest(method: String, path: String, queryItems: [URLQueryItem]?, body: Encodable?) async throws -> T { - let sessionConfig = URLSessionConfiguration.default let session = URLSession(configuration: sessionConfig, delegate: nil, delegateQueue: nil) - guard var URL = URL(string: "\(KnockEnvironment.shared.baseUrl)\(path)") else { - throw Knock.NetworkError(title: "Invalid URL", description: "The URL: \(KnockEnvironment.shared.baseUrl)\(path) is invalid", code: 0) + let loggingMessageSummary = "\(method) \(KnockEnvironment.shared.baseUrl)\(path)" + + guard var URL = URL(string: "\(apiBaseUrl)\(path)") else { + let networkError = Knock.NetworkError(title: "Invalid URL", description: "The URL: \(KnockEnvironment.shared.baseUrl)\(path) is invalid", code: 0) + KnockLogger.log(type: .warning, category: .networking, message: loggingMessageSummary, status: .fail, errorMessage: networkError.localizedDescription) + throw networkError } if queryItems != nil { @@ -46,7 +55,11 @@ internal class KnockAPIService: NSObject { let (responseData, urlResponse) = try await session.data(for: request) let statusCode = (urlResponse as! HTTPURLResponse).statusCode if statusCode < 200 || statusCode > 299 { - throw Knock.NetworkError(title: "Status code error", description: String(data: responseData, encoding: .utf8) ?? "Unknown error", code: statusCode) + let networkError = Knock.NetworkError(title: "Status code error", description: String(data: responseData, encoding: .utf8) ?? "Unknown error", code: statusCode) + KnockLogger.log(type: .warning, category: .networking, message: loggingMessageSummary, status: .fail, errorMessage: networkError.localizedDescription) + throw networkError + } else { + KnockLogger.log(type: .debug, category: .networking, message: loggingMessageSummary, status: .success) } return try decodeData(responseData) @@ -81,7 +94,18 @@ extension KnockAPIService { decoder.dateDecodingStrategy = .formatted(formatter) - let result = try decoder.decode(T.self, from: data) - return result + do { + let result = try decoder.decode(T.self, from: data) + return result + } catch let error { + if let dataString = String(data: data, encoding: .utf8) { + let decodeError = Knock.KnockError.runtimeError("Error decoding data: \(dataString)") + KnockLogger.log(type: .error, category: .networking, message: "Error decoding data: \(dataString)", status: .fail, errorMessage: decodeError.localizedDescription) + throw decodeError + } else { + KnockLogger.log(type: .error, category: .networking, message: "Error processing undecodable data", status: .fail, errorMessage: error.localizedDescription) + throw error + } + } } } diff --git a/Sources/KnockEnvironment.swift b/Sources/KnockEnvironment.swift index f5f3ef6..10998f0 100644 --- a/Sources/KnockEnvironment.swift +++ b/Sources/KnockEnvironment.swift @@ -35,7 +35,7 @@ internal class KnockEnvironment { } func setBaseUrl(baseUrl: String?) { - self.baseUrl = "\(baseUrl ?? KnockEnvironment.defaultBaseUrl)/v1" + self.baseUrl = "\(baseUrl ?? KnockEnvironment.defaultBaseUrl)" } func resetEnvironment() { diff --git a/Sources/KnockLogger.swift b/Sources/KnockLogger.swift new file mode 100644 index 0000000..fb9d649 --- /dev/null +++ b/Sources/KnockLogger.swift @@ -0,0 +1,71 @@ +// +// KnockLogger.swift +// +// +// Created by Matt Gardner on 1/30/24. +// + +import Foundation +import os.log + +internal class KnockLogger { + private static let loggingSubsytem = "knock-swift" + + internal static func log(type: LogType, category: LogCategory, message: String, description: String? = nil, status: LogStatus? = nil, errorMessage: String? = nil, additionalInfo: [String: String]? = nil) { + var composedMessage = "[Knock] " + composedMessage += message + if let description = description { + composedMessage += " | description: \(description)" + } + if let status = status { + composedMessage += " | Status: \(status.rawValue)" + } + if let errorMessage = errorMessage { + composedMessage += " | Error: \(errorMessage)" + } + if let info = additionalInfo { + for (key, value) in info { + composedMessage += " | \(key): \(value)" + } + } + + // Use the Logger API for logging + let logger = Logger(subsystem: loggingSubsytem, category: category.rawValue.capitalized) + switch type { + case .debug: + logger.debug("\(composedMessage)") + case .info: + logger.info("\(composedMessage)") + case .error: + logger.error("\(composedMessage)") + case .warning: + logger.warning("\(composedMessage)") + default: + logger.log("\(composedMessage)") + } + } + + internal enum LogStatus: String { + case success + case fail + } + + internal enum LogType { + case debug + case info + case error + case warning + case log + } + + internal enum LogCategory: String { + case user + case feed + case channel + case preferences + case networking + case pushNotification + case message + case general + } +} diff --git a/Sources/Modules/Channels/ChannelModule.swift b/Sources/Modules/Channels/ChannelModule.swift index 34d9d39..337bcb2 100644 --- a/Sources/Modules/Channels/ChannelModule.swift +++ b/Sources/Modules/Channels/ChannelModule.swift @@ -10,14 +10,27 @@ import OSLog internal class ChannelModule { let channelService = ChannelService() - private let logger: Logger = Logger(subsystem: Knock.loggingSubsytem, category: "Channels") func getUserChannelData(channelId: String) async throws -> Knock.ChannelData { - try await channelService.getUserChannelData(channelId: channelId) + do { + let data = try await channelService.getUserChannelData(channelId: channelId) + KnockLogger.log(type: .debug, category: .channel, message: "getUserChannelData", status: .success) + return data + } catch let error { + KnockLogger.log(type: .warning, category: .channel, message: "getUserChannelData", status: .fail, errorMessage: error.localizedDescription) + throw error + } } func updateUserChannelData(channelId: String, data: AnyEncodable) async throws -> Knock.ChannelData { - try await channelService.updateUserChannelData(channelId: channelId, data: data) + do { + let data = try await channelService.updateUserChannelData(channelId: channelId, data: data) + KnockLogger.log(type: .debug, category: .channel, message: "updateUserChannelData", status: .success) + return data + } catch let error { + KnockLogger.log(type: .warning, category: .channel, message: "updateUserChannelData", status: .fail, errorMessage: error.localizedDescription) + throw error + } } private func registerOrUpdateToken(token: String, channelId: String, existingTokens: [String]?) async throws -> Knock.ChannelData { @@ -27,7 +40,9 @@ internal class ChannelModule { } let data: AnyEncodable = ["tokens": tokens] - return try await updateUserChannelData(channelId: channelId, data: data) + let channelData = try await updateUserChannelData(channelId: channelId, data: data) + KnockLogger.log(type: .debug, category: .pushNotification, message: "registerOrUpdateToken", status: .success) + return channelData } func registerTokenForAPNS(channelId: String, token: String) async throws -> Knock.ChannelData { @@ -48,7 +63,7 @@ internal class ChannelModule { return try await registerOrUpdateToken(token: token, channelId: channelId, existingTokens: tokens) } } catch let userIdError as Knock.KnockError where userIdError == Knock.KnockError.userIdError { - logger.warning("[Knock] ChannelId and deviceToken were saved. However, we cannot register for APNS until you have have called Knock.signIn().") + KnockLogger.log(type: .warning, category: .pushNotification, message: "ChannelId and deviceToken were saved. However, we cannot register for APNS until you have have called Knock.signIn().") throw userIdError } catch { // No data registered on that channel for that user, we'll create a new record @@ -61,7 +76,7 @@ internal class ChannelModule { let channelData = try await getUserChannelData(channelId: channelId) guard let data = channelData.data, let tokens = data["tokens"]?.value as? [String] else { // No valid tokens array found. - self.logger.warning("[Knock] Could not unregister user from channel \(channelId). Reason: User doesn't have any device tokens associated to the provided channelId.") + KnockLogger.log(type: .warning, category: .pushNotification, message: "unregisterTokenForAPNS", description: "Could not unregister user from channel \(channelId). Reason: User doesn't have any device tokens associated to the provided channelId.") return channelData } @@ -71,19 +86,21 @@ internal class ChannelModule { let data: AnyEncodable = [ "tokens": newTokens ] - return try await updateUserChannelData(channelId: channelId, data: data) + let updateData = try await updateUserChannelData(channelId: channelId, data: data) + KnockLogger.log(type: .debug, category: .pushNotification, message: "unregisterTokenForAPNS", status: .success) + return updateData } else { - self.logger.warning("[Knock] Could not unregister user from channel \(channelId). Reason: User doesn't have any device tokens that match the token provided.") + KnockLogger.log(type: .warning, category: .pushNotification, message: "unregisterTokenForAPNS", description: "Could not unregister user from channel \(channelId). Reason: User doesn't have any device tokens associated to the provided channelId.") return channelData } } catch { if let networkError = error as? Knock.NetworkError, networkError.code == 404 { // No data registered on that channel for that user - self.logger.warning("[Knock] Could not unregister user from channel \(channelId). Reason: User doesn't have any channel data associated to the provided channelId.") + KnockLogger.log(type: .warning, category: .pushNotification, message: "unregisterTokenForAPNS", description: "Could not unregister user from channel \(channelId). Reason: User doesn't have any channel data associated to the provided channelId.") return .init(channel_id: channelId, data: [:]) } else { // Unknown error. Could be network or server related. Try again. - self.logger.error("[Knock] Could not unregister user from channel \(channelId). Please try again. Reason: \(error.localizedDescription)") + KnockLogger.log(type: .error, category: .pushNotification, message: "unregisterTokenForAPNS", description: "Could not unregister user from channel \(channelId)", status: .fail, errorMessage: error.localizedDescription) throw error } } diff --git a/Sources/Modules/Feed/FeedModule.swift b/Sources/Modules/Feed/FeedModule.swift index 21424b4..d6135f3 100644 --- a/Sources/Modules/Feed/FeedModule.swift +++ b/Sources/Modules/Feed/FeedModule.swift @@ -15,18 +15,25 @@ internal class FeedModule { private let feedId: String private var feedTopic: String private var feedOptions: Knock.FeedClientOptions - private let logger: Logger = Logger(subsystem: Knock.loggingSubsytem, category: "Feed") private let feedService = FeedService() internal init(feedId: String, options: Knock.FeedClientOptions) throws { // use regex and circumflex accent to mark only the starting http to be replaced and not any others let websocketHostname = KnockEnvironment.shared.baseUrl.replacingOccurrences(of: "^http", with: "ws", options: .regularExpression) // default: wss://api.knock.app let websocketPath = "\(websocketHostname)/ws/v1/websocket" // default: wss://api.knock.app/ws/v1/websocket - let userId = try KnockEnvironment.shared.getSafeUserId() + var userId = "" + do { + userId = try KnockEnvironment.shared.getSafeUserId() + } catch let error { + KnockLogger.log(type: .error, category: .feed, message: "FeedManager", status: .fail, errorMessage: "Must sign user in before initializing the FeedManager") + throw error + } + self.socket = Socket(websocketPath, params: ["vsn": "2.0.0", "api_key": KnockEnvironment.shared.publishableKey, "user_token": KnockEnvironment.shared.userToken ?? ""]) self.feedId = feedId self.feedTopic = "feeds:\(feedId):\(userId)" self.feedOptions = options + KnockLogger.log(type: .debug, category: .feed, message: "FeedManager", status: .success) } func getUserFeedContent(options: Knock.FeedClientOptions? = nil) async throws -> Knock.Feed { @@ -46,7 +53,14 @@ internal class FeedModule { URLQueryItem(name: "trigger_data", value: triggerDataJSON) ] - return try await feedService.getUserFeedContent(queryItems: queryItems, feedId: feedId) + do { + let feed = try await feedService.getUserFeedContent(queryItems: queryItems, feedId: feedId) + KnockLogger.log(type: .debug, category: .feed, message: "getUserFeedContent", status: .success) + return feed + } catch let error { + KnockLogger.log(type: .error, category: .feed, message: "getUserFeedContent", status: .fail, errorMessage: error.localizedDescription) + throw error + } } func makeBulkStatusUpdate(type: Knock.BulkChannelMessageStatusUpdateType, options: Knock.FeedClientOptions) async throws -> Knock.BulkOperation { @@ -64,11 +78,18 @@ internal class FeedModule { "has_tenant": options.has_tenant ?? "", "tenants": (options.tenant != nil) ? [options.tenant!] : "" ] - return try await feedService.makeBulkStatusUpdate(feedId: feedId, type: type, body: body) + do { + let op = try await feedService.makeBulkStatusUpdate(feedId: feedId, type: type, body: body) + KnockLogger.log(type: .debug, category: .feed, message: "makeBulkStatusUpdate", status: .success) + return op + } catch let error { + KnockLogger.log(type: .error, category: .feed, message: "makeBulkStatusUpdate", status: .fail, errorMessage: error.localizedDescription) + throw error + } } func disconnectFromFeed() { - logger.debug("[Knock] Disconnecting from feed") + KnockLogger.log(type: .debug, category: .feed, message: "Disconnecting from feed") if let channel = self.feedChannel { channel.leave() @@ -86,32 +107,34 @@ internal class FeedModule { } } else { - logger.error("[Knock] Feed channel is nil. You should call first connectToFeed()") + KnockLogger.log(type: .error, category: .feed, message: "FeedManager.on", status: .fail, errorMessage: "Feed channel is nil. You should call first connectToFeed()") } } func connectToFeed(options: Knock.FeedClientOptions? = nil) { // Setup the socket to receive open/close events socket.delegateOnOpen(to: self) { (self) in - self.logger.debug("[Knock] Socket Opened") + KnockLogger.log(type: .debug, category: .feed, message: "connectToFeed", description: "Socket Opened") } socket.delegateOnClose(to: self) { (self) in - self.logger.debug("[Knock] Socket Closed") + KnockLogger.log(type: .debug, category: .feed, message: "connectToFeed", description: "Socket Closed") } socket.delegateOnError(to: self) { (self, error) in let (error, response) = error if let statusCode = (response as? HTTPURLResponse)?.statusCode, statusCode > 400 { - self.logger.error("[Knock] Socket Errored. \(statusCode)") + KnockLogger.log(type: .error, category: .feed, message: "connectToFeed", description: "Socket Errored \(statusCode)", status: .fail, errorMessage: error.localizedDescription) self.socket.disconnect() } else { - self.logger.error("[Knock] Socket Errored. \(error)") + KnockLogger.log(type: .error, category: .feed, message: "connectToFeed", description: "Socket Errored", status: .fail, errorMessage: error.localizedDescription) } } // TODO: Determine the level of logging we want from SwiftPhoenixClient. Currently this produces a lot of noise. -// socket.logger = { msg in print("LOG:", msg) } + socket.logger = { msg in + KnockLogger.log(type: .debug, category: .feed, message: "SwiftPhoenixClient", description: msg) + } let mergedOptions = feedOptions.mergeOptions(options: options) @@ -125,10 +148,10 @@ internal class FeedModule { self.feedChannel? .join() .delegateReceive("ok", to: self) { (self, _) in - self.logger.debug("[Knock] CHANNEL: \(channel.topic) joined") + KnockLogger.log(type: .debug, category: .feed, message: "connectToFeed", description: "CHANNEL: \(channel.topic) joined") } .delegateReceive("error", to: self) { (self, message) in - self.logger.debug("[Knock] CHANNEL: \(channel.topic) failed to join. \(message.payload)") + KnockLogger.log(type: .error, category: .feed, message: "connectToFeed", status: .fail, errorMessage: "CHANNEL: \(channel.topic) failed to join. \(message.payload)") } self.socket.connect() diff --git a/Sources/Modules/Messages/MessageModule.swift b/Sources/Modules/Messages/MessageModule.swift index 1f147d7..c47c078 100644 --- a/Sources/Modules/Messages/MessageModule.swift +++ b/Sources/Modules/Messages/MessageModule.swift @@ -11,19 +11,47 @@ internal class MessageModule { let messageService = MessageService() internal func getMessage(messageId: String) async throws -> Knock.KnockMessage { - try await messageService.getMessage(messageId: messageId) + do { + let message = try await messageService.getMessage(messageId: messageId) + KnockLogger.log(type: .debug, category: .message, message: "getMessage", status: .success, additionalInfo: ["messageId": messageId]) + return message + } catch let error { + KnockLogger.log(type: .error, category: .message, message: "getMessage", status: .fail, errorMessage: error.localizedDescription, additionalInfo: ["messageId": messageId]) + throw error + } } internal func updateMessageStatus(messageId: String, status: Knock.KnockMessageStatusUpdateType) async throws -> Knock.KnockMessage { - try await messageService.updateMessageStatus(messageId: messageId, status: status) + do { + let message = try await messageService.updateMessageStatus(messageId: messageId, status: status) + KnockLogger.log(type: .debug, category: .message, message: "updateMessageStatus", status: .success, additionalInfo: ["messageId": messageId]) + return message + } catch let error { + KnockLogger.log(type: .error, category: .message, message: "updateMessageStatus", status: .fail, errorMessage: error.localizedDescription, additionalInfo: ["messageId": messageId]) + throw error + } } internal func deleteMessageStatus(messageId: String, status: Knock.KnockMessageStatusUpdateType) async throws -> Knock.KnockMessage { - try await messageService.deleteMessageStatus(messageId: messageId, status: status) + do { + let message = try await messageService.deleteMessageStatus(messageId: messageId, status: status) + KnockLogger.log(type: .debug, category: .message, message: "deleteMessageStatus", status: .success, additionalInfo: ["messageId": messageId]) + return message + } catch let error { + KnockLogger.log(type: .error, category: .message, message: "deleteMessageStatus", status: .fail, errorMessage: error.localizedDescription, additionalInfo: ["messageId": messageId]) + throw error + } } internal func batchUpdateStatuses(messageIds: [String], status: Knock.KnockMessageStatusBatchUpdateType) async throws -> [Knock.KnockMessage] { - try await messageService.batchUpdateStatuses(messageIds: messageIds, status: status) + do { + let messages = try await messageService.batchUpdateStatuses(messageIds: messageIds, status: status) + KnockLogger.log(type: .debug, category: .message, message: "batchUpdateStatuses", status: .success) + return messages + } catch let error { + KnockLogger.log(type: .error, category: .message, message: "batchUpdateStatuses", status: .fail, errorMessage: error.localizedDescription) + throw error + } } } diff --git a/Sources/Modules/Preferences/PreferenceModule.swift b/Sources/Modules/Preferences/PreferenceModule.swift index 4e08dc2..16dc08b 100644 --- a/Sources/Modules/Preferences/PreferenceModule.swift +++ b/Sources/Modules/Preferences/PreferenceModule.swift @@ -12,15 +12,36 @@ internal class PreferenceModule { let preferenceService = PreferenceService() internal func getAllUserPreferences() async throws -> [Knock.PreferenceSet] { - try await preferenceService.getAllUserPreferences() + do { + let set = try await preferenceService.getAllUserPreferences() + KnockLogger.log(type: .debug, category: .preferences, message: "getAllUserPreferences", status: .success) + return set + } catch let error { + KnockLogger.log(type: .error, category: .preferences, message: "getAllUserPreferences", status: .fail, errorMessage: error.localizedDescription) + throw error + } } internal func getUserPreferences(preferenceId: String) async throws -> Knock.PreferenceSet { - try await preferenceService.getUserPreferences(preferenceId: preferenceId) + do { + let set = try await preferenceService.getUserPreferences(preferenceId: preferenceId) + KnockLogger.log(type: .debug, category: .preferences, message: "getUserPreferences", status: .success) + return set + } catch let error { + KnockLogger.log(type: .error, category: .preferences, message: "getUserPreferences", status: .fail, errorMessage: error.localizedDescription) + throw error + } } internal func setUserPreferences(preferenceId: String, preferenceSet: Knock.PreferenceSet) async throws -> Knock.PreferenceSet { - try await preferenceService.setUserPreferences(preferenceId: preferenceId, preferenceSet: preferenceSet) + do { + let set = try await preferenceService.setUserPreferences(preferenceId: preferenceId, preferenceSet: preferenceSet) + KnockLogger.log(type: .debug, category: .preferences, message: "setUserPreferences", status: .success) + return set + } catch let error { + KnockLogger.log(type: .error, category: .preferences, message: "setUserPreferences", status: .fail, errorMessage: error.localizedDescription) + throw error + } } } @@ -44,6 +65,7 @@ public extension Knock { try await self.preferenceModule.getUserPreferences(preferenceId: preferenceId) } + @MainActor func getUserPreferences(preferenceId: String, completionHandler: @escaping ((Result) -> Void)) { Task { do { diff --git a/Sources/Modules/Users/UserModule.swift b/Sources/Modules/Users/UserModule.swift index 9c2c2be..841fb5d 100644 --- a/Sources/Modules/Users/UserModule.swift +++ b/Sources/Modules/Users/UserModule.swift @@ -10,14 +10,27 @@ import OSLog internal class UserModule { let userService = UserService() - private let logger: Logger = Logger(subsystem: Knock.loggingSubsytem, category: "User") func getUser() async throws -> Knock.User { - return try await userService.getUser() + do { + let user = try await userService.getUser() + KnockLogger.log(type: .debug, category: .user, message: "getUser", status: .success) + return user + } catch let error { + KnockLogger.log(type: .error, category: .user, message: "getUser", status: .fail, errorMessage: error.localizedDescription) + throw error + } } func updateUser(user: Knock.User) async throws -> Knock.User { - return try await userService.updateUser(user: user) + do { + let user = try await userService.updateUser(user: user) + KnockLogger.log(type: .debug, category: .user, message: "updateUser", status: .success) + return user + } catch let error { + KnockLogger.log(type: .error, category: .user, message: "updateUser", status: .fail, errorMessage: error.localizedDescription) + throw error + } } } From 3a6006711aae28b1798169326f63d28a66981dfc Mon Sep 17 00:00:00 2001 From: Matt Gardner Date: Tue, 30 Jan 2024 16:23:54 -0700 Subject: [PATCH 18/22] Working on tests --- .../xcshareddata/xcschemes/Knock.xcscheme | 12 +++++++ Package.swift | 7 ++-- Sources/Knock.swift | 8 ++--- Sources/KnockAppDelegate.swift | 16 ++++----- Sources/KnockEnvironment.swift | 8 +++-- Sources/KnockLogger.swift | 1 + .../KnockTests}/KnockTests.swift | 34 +++++++------------ 7 files changed, 47 insertions(+), 39 deletions(-) rename {KnockTests => Tests/KnockTests}/KnockTests.swift (59%) diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/Knock.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/Knock.xcscheme index 19db893..f5f8b6e 100644 --- a/.swiftpm/xcode/xcshareddata/xcschemes/Knock.xcscheme +++ b/.swiftpm/xcode/xcshareddata/xcschemes/Knock.xcscheme @@ -28,6 +28,18 @@ selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv = "YES" shouldAutocreateTestPlan = "YES"> + + + + + + Void) { - logger.debug("userNotificationCenter willPresent notification: \(notification)") + KnockLogger.log(type: .debug, category: .appDelegate, message: "userNotificationCenter willPresent notification", description: "\(notification)") let userInfo = notification.request.content.userInfo @@ -43,8 +41,8 @@ open class KnockAppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificat } open func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { - logger.debug("didReceiveNotificationResponse: \(response)") - + KnockLogger.log(type: .debug, category: .appDelegate, message: "didReceiveNotificationResponse", description: "\(response)") + let userInfo = response.notification.request.content.userInfo if response.actionIdentifier == UNNotificationDismissActionIdentifier { @@ -58,19 +56,17 @@ open class KnockAppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificat // MARK: Token Management open func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) { - logger.error("Failed to register for notifications: \(error.localizedDescription)") + KnockLogger.log(type: .error, category: .appDelegate, message: "Failed to register for notifications", description: error.localizedDescription) } open func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { - logger.debug("Successfully registered for notifications!") - // 1. Convert device token to string let tokenParts = deviceToken.map { data -> String in return String(format: "%02.2hhx", data) } let token = tokenParts.joined() // 2. Print device token to use for PNs payloads - logger.debug("Device Token: \(token)") + KnockLogger.log(type: .debug, category: .appDelegate, message: "Successfully registered for notifications!", description: "Device Token: \(token)") let defaults = UserDefaults.standard defaults.set(token, forKey: "device_push_token") diff --git a/Sources/KnockEnvironment.swift b/Sources/KnockEnvironment.swift index 10998f0..f40335f 100644 --- a/Sources/KnockEnvironment.swift +++ b/Sources/KnockEnvironment.swift @@ -19,8 +19,12 @@ internal class KnockEnvironment { private(set) var publishableKey: String = "" private(set) var baseUrl: String = "" - func setPublishableKey(key: String) { - guard key.hasPrefix("sk_") == false else { fatalError("[Knock] You are using your secret API key on the client. Please use the public key.") } + func setPublishableKey(key: String) throws { + guard key.hasPrefix("sk_") == false else { + let error = Knock.KnockError.runtimeError("You are using your secret API key on the client. Please use the public key.") + KnockLogger.log(type: .error, category: .general, message: "setPublishableKey", status: .fail, errorMessage: error.localizedDescription) + throw error + } self.publishableKey = key } diff --git a/Sources/KnockLogger.swift b/Sources/KnockLogger.swift index fb9d649..a4f8738 100644 --- a/Sources/KnockLogger.swift +++ b/Sources/KnockLogger.swift @@ -67,5 +67,6 @@ internal class KnockLogger { case pushNotification case message case general + case appDelegate } } diff --git a/KnockTests/KnockTests.swift b/Tests/KnockTests/KnockTests.swift similarity index 59% rename from KnockTests/KnockTests.swift rename to Tests/KnockTests/KnockTests.swift index b047201..d76a0e0 100644 --- a/KnockTests/KnockTests.swift +++ b/Tests/KnockTests/KnockTests.swift @@ -9,27 +9,28 @@ import XCTest @testable import Knock final class KnockTests: XCTestCase { - + var knock: Knock! + override func setUpWithError() throws { - // Put setup code here. This method is called before the invocation of each test method in the class. + knock = try! Knock(publishableKey: "pk_test") } override func tearDownWithError() throws { // Put teardown code here. This method is called after the invocation of each test method in the class. } - func testExample() throws { - // This is an example of a functional test case. - // Use XCTAssert and related functions to verify your tests produce the correct results. - // Any test you write for XCTest can be annotated as throws and async. - // Mark your test throws to produce an unexpected failure when your test encounters an uncaught error. - // Mark your test async to allow awaiting for asynchronous code to complete. Check the results with assertions afterwards. - - // check that initializing the client with a secret key prefix throws - XCTAssertThrowsError(try Knock(publishableKey: "sk_123", userId: "")) + func testPublishableKeyError() throws { + XCTAssertThrowsError(try Knock(publishableKey: "sk_123")) + } + + func testUserIdNilError() async throws { + do { + _ = try await knock.getUser() + XCTFail("Expected getUser() to throw, but it did not.") + } catch {} } - func testUserDecoding1() throws { + func testUserDecoding() throws { let decoder = JSONDecoder() let formatter = DateFormatter() @@ -68,13 +69,4 @@ final class KnockTests: XCTestCase { XCTAssertTrue(reencodedString.contains("extra2")) XCTAssertTrue(reencodedString.contains("a1")) } - - func testPerformanceExample() throws { - // This is an example of a performance test case. - self.measure { - // Put the code you want to measure the time of here. - let _ = try! Knock(publishableKey: "pk_123", userId: "u-123") - } - } - } From 16208158a9646b2bb06f26d8931f5b6d64589db2 Mon Sep 17 00:00:00 2001 From: Matt Gardner Date: Wed, 31 Jan 2024 09:33:47 -0700 Subject: [PATCH 19/22] created shared Knock instance --- Sources/Knock.swift | 30 ++--- Sources/KnockAPIService.swift | 19 ++-- Sources/KnockAppDelegate.swift | 1 + Sources/KnockEnvironment.swift | 29 +++-- Sources/KnockErrors.swift | 3 + .../Authentication/AuthenticationModule.swift | 83 ++++++++++++++ Sources/Modules/Channels/ChannelModule.swift | 2 +- Sources/Modules/Channels/ChannelService.swift | 4 +- Sources/Modules/Feed/FeedModule.swift | 8 +- Sources/Modules/Feed/FeedService.swift | 2 +- .../Preferences/PreferenceService.swift | 6 +- Sources/Modules/Users/UserModule.swift | 103 ------------------ Sources/Modules/Users/UserService.swift | 4 +- 13 files changed, 145 insertions(+), 149 deletions(-) create mode 100644 Sources/Modules/Authentication/AuthenticationModule.swift diff --git a/Sources/Knock.swift b/Sources/Knock.swift index 34b05c8..15c29f0 100644 --- a/Sources/Knock.swift +++ b/Sources/Knock.swift @@ -11,10 +11,13 @@ import OSLog // Knock client SDK. public class Knock { internal static let clientVersion = "1.0.0" - internal static let loggingSubsytem = "knock-swift" - + + public static let shared: Knock = Knock() + public var feedManager: FeedManager? + internal let environment = KnockEnvironment() + internal lazy var authenticationModule = AuthenticationModule() internal lazy var userModule = UserModule() internal lazy var preferenceModule = PreferenceModule() internal lazy var messageModule = MessageModule() @@ -27,28 +30,29 @@ public class Knock { - publishableKey: your public API key - options: [optional] Options for customizing the Knock instance. */ - public init(publishableKey: String, hostname: String? = nil) throws { - try KnockEnvironment.shared.setPublishableKey(key: publishableKey) - KnockEnvironment.shared.setBaseUrl(baseUrl: hostname) + public func setup(publishableKey: String, pushChannelId: String?, hostname: String? = nil) throws { + try environment.setPublishableKey(key: publishableKey) + environment.setBaseUrl(baseUrl: hostname) + environment.pushChannelId = pushChannelId } - @available(*, deprecated, message: "See v1.0.0 migration guide for more details.") - public init(publishableKey: String, userId: String, userToken: String? = nil, hostname: String? = nil) throws { - try KnockEnvironment.shared.setPublishableKey(key: publishableKey) - KnockEnvironment.shared.setUserInfo(userId: userId, userToken: userToken) - KnockEnvironment.shared.setBaseUrl(baseUrl: hostname) - } +// @available(*, deprecated, message: "See v1.0.0 migration guide for more details.") +// public init(publishableKey: String, userId: String, userToken: String? = nil, hostname: String? = nil) throws { +// try KnockEnvironment.shared.setPublishableKey(key: publishableKey) +// KnockEnvironment.shared.setUserInfo(userId: userId, userToken: userToken) +// KnockEnvironment.shared.setBaseUrl(baseUrl: hostname) +// } internal func resetInstance() { self.feedManager = nil - KnockEnvironment.shared.resetEnvironment() + environment.resetEnvironment() } } public extension Knock { var userId: String? { get { - return KnockEnvironment.shared.userId + return environment.userId } } } diff --git a/Sources/KnockAPIService.swift b/Sources/KnockAPIService.swift index 62d6583..1a3b405 100644 --- a/Sources/KnockAPIService.swift +++ b/Sources/KnockAPIService.swift @@ -8,21 +8,24 @@ import Foundation import OSLog -internal class KnockAPIService: NSObject { - private let logger: Logger = Logger(subsystem: Knock.loggingSubsytem, category: "Network") - +internal class KnockAPIService: NSObject { private let apiVersion = "v1" private var apiBaseUrl: String { - return "\(KnockEnvironment.shared.baseUrl)/v1" + return "\(Knock.shared.environment.baseUrl)/v1" + } + + internal func getSafeUserId() throws -> String { + try Knock.shared.environment.getSafeUserId() } func makeRequest(method: String, path: String, queryItems: [URLQueryItem]?, body: Encodable?) async throws -> T { let sessionConfig = URLSessionConfiguration.default let session = URLSession(configuration: sessionConfig, delegate: nil, delegateQueue: nil) - let loggingMessageSummary = "\(method) \(KnockEnvironment.shared.baseUrl)\(path)" + + let loggingMessageSummary = "\(method) \(apiBaseUrl)\(path)" guard var URL = URL(string: "\(apiBaseUrl)\(path)") else { - let networkError = Knock.NetworkError(title: "Invalid URL", description: "The URL: \(KnockEnvironment.shared.baseUrl)\(path) is invalid", code: 0) + let networkError = Knock.NetworkError(title: "Invalid URL", description: "The URL: \(apiBaseUrl)\(path) is invalid", code: 0) KnockLogger.log(type: .warning, category: .networking, message: loggingMessageSummary, status: .fail, errorMessage: networkError.localizedDescription) throw networkError } @@ -46,8 +49,8 @@ internal class KnockAPIService: NSObject { request.addValue("knock-swift@\(Knock.clientVersion)", forHTTPHeaderField: "User-Agent") - request.addValue("Bearer \(KnockEnvironment.shared.publishableKey)", forHTTPHeaderField: "Authorization") - if let userToken = KnockEnvironment.shared.userToken { + request.addValue("Bearer \(try Knock.shared.environment.getSafePublishableKey())", forHTTPHeaderField: "Authorization") + if let userToken = Knock.shared.environment.userToken { request.addValue(userToken, forHTTPHeaderField: "X-Knock-User-Token") } diff --git a/Sources/KnockAppDelegate.swift b/Sources/KnockAppDelegate.swift index c2313b6..6159161 100644 --- a/Sources/KnockAppDelegate.swift +++ b/Sources/KnockAppDelegate.swift @@ -66,6 +66,7 @@ open class KnockAppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificat } let token = tokenParts.joined() // 2. Print device token to use for PNs payloads + Knock.shared.registerTokenForAPNS(channelId: <#T##String#>, token: <#T##String#>, completionHandler: <#T##((Result) -> Void)##((Result) -> Void)##(Result) -> Void#>) KnockLogger.log(type: .debug, category: .appDelegate, message: "Successfully registered for notifications!", description: "Device Token: \(token)") let defaults = UserDefaults.standard diff --git a/Sources/KnockEnvironment.swift b/Sources/KnockEnvironment.swift index f40335f..2924bd0 100644 --- a/Sources/KnockEnvironment.swift +++ b/Sources/KnockEnvironment.swift @@ -8,30 +8,28 @@ import Foundation internal class KnockEnvironment { - static let shared = KnockEnvironment() - static let defaultBaseUrl: String = "https://api.knock.app" private(set) var userId: String? private(set) var userToken: String? - private(set) var userDevicePushToken: String? - private(set) var pushChannelId: String? - private(set) var publishableKey: String = "" - private(set) var baseUrl: String = "" + internal(set) var userDevicePushToken: String? + internal(set) var pushChannelId: String? + private(set) var publishableKey: String? + private(set) var baseUrl: String = defaultBaseUrl func setPublishableKey(key: String) throws { guard key.hasPrefix("sk_") == false else { - let error = Knock.KnockError.runtimeError("You are using your secret API key on the client. Please use the public key.") + let error = Knock.KnockError.publishableKeyError("You are using your secret API key on the client. Please use the public key.") KnockLogger.log(type: .error, category: .general, message: "setPublishableKey", status: .fail, errorMessage: error.localizedDescription) throw error } self.publishableKey = key } - func setPushInformation(channelId: String?, deviceToken: String?) { - self.pushChannelId = channelId - self.userDevicePushToken = deviceToken - } +// func setPushInformation(channelId: String?, deviceToken: String?) { +// self.pushChannelId = channelId +// self.userDevicePushToken = deviceToken +// } func setUserInfo(userId: String?, userToken: String?) { self.userId = userId @@ -48,9 +46,16 @@ internal class KnockEnvironment { } func getSafeUserId() throws -> String { - guard let id = KnockEnvironment.shared.userId else { + guard let id = userId else { throw Knock.KnockError.userIdError } return id } + + func getSafePublishableKey() throws -> String { + guard let id = publishableKey else { + throw Knock.KnockError.publishableKeyError("You are trying to perform an action that requires you to first run Knock.shared.setup()") + } + return id + } } diff --git a/Sources/KnockErrors.swift b/Sources/KnockErrors.swift index 4f25e2c..4a42cb9 100644 --- a/Sources/KnockErrors.swift +++ b/Sources/KnockErrors.swift @@ -11,6 +11,7 @@ public extension Knock { enum KnockError: Error, Equatable { case runtimeError(String) case userIdError + case publishableKeyError(String) } struct NetworkError: NetworkErrorProtocol { @@ -36,6 +37,8 @@ extension Knock.KnockError: LocalizedError { return message case .userIdError: return "UserId not found. Please authenticate your userId with Knock.authenticate()." + case .publishableKeyError(let message): + return message } } } diff --git a/Sources/Modules/Authentication/AuthenticationModule.swift b/Sources/Modules/Authentication/AuthenticationModule.swift new file mode 100644 index 0000000..417b0ff --- /dev/null +++ b/Sources/Modules/Authentication/AuthenticationModule.swift @@ -0,0 +1,83 @@ +// +// AuthenticationModule.swift +// +// +// Created by Matt Gardner on 1/30/24. +// + +import Foundation + +internal class AuthenticationModule { + + func signIn(userId: String, userToken: String?) async throws { + Knock.shared.environment.setUserInfo(userId: userId, userToken: userToken) + + if let token = Knock.shared.environment.userDevicePushToken, let channelId = Knock.shared.environment.pushChannelId { + let _ = try await Knock.shared.channelModule.registerTokenForAPNS(channelId: channelId, token: token) + return + } + + return + } + + func signOut() async throws { + guard let channelId = Knock.shared.environment.pushChannelId, let token = Knock.shared.environment.userDevicePushToken else { + Knock.shared.resetInstance() + return + } + let _ = try await Knock.shared.channelModule.unregisterTokenForAPNS(channelId: channelId, token: token) + Knock.shared.resetInstance() + return + } +} + +public extension Knock { + + func isAuthenticated(checkUserToken: Bool = false) -> Bool { + let isUser = Knock.shared.environment.userId?.isEmpty == false + if checkUserToken { + return isUser && Knock.shared.environment.userToken?.isEmpty == false + } + return isUser + } + + /** + Set the current credentials for the user and their access token. + Will also registerAPNS device token if set previously. + You should consider using this in areas where you update your local user's state + */ + func signIn(userId: String, userToken: String?) async throws { + try await authenticationModule.signIn(userId: userId, userToken: userToken) + } + + func signIn(userId: String, userToken: String?, completionHandler: @escaping ((Result) -> Void)) { + Task { + do { + try await signIn(userId: userId, userToken: userToken) + completionHandler(.success(())) + } catch { + completionHandler(.failure(error)) + } + } + } + + /** + Clears the current user id and access token + You should call this when your user signs out + It will remove the current tokens used for this user in Courier so they do not receive pushes they should not get + */ + func signOut() async throws { + try await authenticationModule.signOut() + } + + func signOut(completionHandler: @escaping ((Result) -> Void)) { + Task { + do { + try await signOut() + completionHandler(.success(())) + } catch { + completionHandler(.failure(error)) + } + } + } +} diff --git a/Sources/Modules/Channels/ChannelModule.swift b/Sources/Modules/Channels/ChannelModule.swift index 337bcb2..a2f87a8 100644 --- a/Sources/Modules/Channels/ChannelModule.swift +++ b/Sources/Modules/Channels/ChannelModule.swift @@ -46,7 +46,7 @@ internal class ChannelModule { } func registerTokenForAPNS(channelId: String, token: String) async throws -> Knock.ChannelData { - KnockEnvironment.shared.setPushInformation(channelId: channelId, deviceToken: token) + Knock.shared.environment.setPushInformation(channelId: channelId, deviceToken: token) do { let channelData = try await getUserChannelData(channelId: channelId) diff --git a/Sources/Modules/Channels/ChannelService.swift b/Sources/Modules/Channels/ChannelService.swift index 181cc23..750dd19 100644 --- a/Sources/Modules/Channels/ChannelService.swift +++ b/Sources/Modules/Channels/ChannelService.swift @@ -11,11 +11,11 @@ import OSLog internal class ChannelService: KnockAPIService { func getUserChannelData(channelId: String) async throws -> Knock.ChannelData { - try await get(path: "/users/\(KnockEnvironment.shared.getSafeUserId())/channel_data/\(channelId)", queryItems: nil) + try await get(path: "/users/\(getSafeUserId())/channel_data/\(channelId)", queryItems: nil) } func updateUserChannelData(channelId: String, data: AnyEncodable) async throws -> Knock.ChannelData { let body = ["data": data] - return try await put(path: "/users/\(KnockEnvironment.shared.getSafeUserId())/channel_data/\(channelId)", body: body) + return try await put(path: "/users/\(getSafeUserId())/channel_data/\(channelId)", body: body) } } diff --git a/Sources/Modules/Feed/FeedModule.swift b/Sources/Modules/Feed/FeedModule.swift index d6135f3..ee3b76e 100644 --- a/Sources/Modules/Feed/FeedModule.swift +++ b/Sources/Modules/Feed/FeedModule.swift @@ -19,17 +19,17 @@ internal class FeedModule { internal init(feedId: String, options: Knock.FeedClientOptions) throws { // use regex and circumflex accent to mark only the starting http to be replaced and not any others - let websocketHostname = KnockEnvironment.shared.baseUrl.replacingOccurrences(of: "^http", with: "ws", options: .regularExpression) // default: wss://api.knock.app + let websocketHostname = Knock.shared.environment.baseUrl.replacingOccurrences(of: "^http", with: "ws", options: .regularExpression) // default: wss://api.knock.app let websocketPath = "\(websocketHostname)/ws/v1/websocket" // default: wss://api.knock.app/ws/v1/websocket var userId = "" do { - userId = try KnockEnvironment.shared.getSafeUserId() + userId = try Knock.shared.environment.getSafeUserId() } catch let error { KnockLogger.log(type: .error, category: .feed, message: "FeedManager", status: .fail, errorMessage: "Must sign user in before initializing the FeedManager") throw error } - self.socket = Socket(websocketPath, params: ["vsn": "2.0.0", "api_key": KnockEnvironment.shared.publishableKey, "user_token": KnockEnvironment.shared.userToken ?? ""]) + self.socket = Socket(websocketPath, params: ["vsn": "2.0.0", "api_key": try Knock.shared.environment.getSafePublishableKey(), "user_token": Knock.shared.environment.userToken ?? ""]) self.feedId = feedId self.feedTopic = "feeds:\(feedId):\(userId)" self.feedOptions = options @@ -70,7 +70,7 @@ internal class FeedModule { // delivery_status: one of `queued`, `sent`, `delivered`, `delivery_attempted`, `undelivered`, `not_sent` // engagement_status: one of `seen`, `unseen`, `read`, `unread`, `archived`, `unarchived`, `interacted` // Also check if the parameters sent here are valid - let userId = try KnockEnvironment.shared.getSafeUserId() + let userId = try Knock.shared.environment.getSafeUserId() let body: AnyEncodable = [ "user_ids": [userId], "engagement_status": options.status != nil && options.status != .all ? options.status!.rawValue : "", diff --git a/Sources/Modules/Feed/FeedService.swift b/Sources/Modules/Feed/FeedService.swift index 602b153..0c94f40 100644 --- a/Sources/Modules/Feed/FeedService.swift +++ b/Sources/Modules/Feed/FeedService.swift @@ -9,7 +9,7 @@ import Foundation internal class FeedService: KnockAPIService { func getUserFeedContent(queryItems: [URLQueryItem]?, feedId: String) async throws -> Knock.Feed { - try await get(path: "/users/\(KnockEnvironment.shared.getSafeUserId())/feeds/\(feedId)", queryItems: queryItems) + try await get(path: "/users/\(getSafeUserId())/feeds/\(feedId)", queryItems: queryItems) } func makeBulkStatusUpdate(feedId: String, type: Knock.BulkChannelMessageStatusUpdateType, body: AnyEncodable?) async throws -> Knock.BulkOperation { diff --git a/Sources/Modules/Preferences/PreferenceService.swift b/Sources/Modules/Preferences/PreferenceService.swift index 5a18e8b..083ef65 100644 --- a/Sources/Modules/Preferences/PreferenceService.swift +++ b/Sources/Modules/Preferences/PreferenceService.swift @@ -10,14 +10,14 @@ import Foundation internal class PreferenceService: KnockAPIService { internal func getAllUserPreferences() async throws -> [Knock.PreferenceSet] { - try await get(path: "/users/\(KnockEnvironment.shared.getSafeUserId())/preferences", queryItems: nil) + try await get(path: "/users/\(getSafeUserId())/preferences", queryItems: nil) } internal func getUserPreferences(preferenceId: String) async throws -> Knock.PreferenceSet { - try await get(path: "/users/\(KnockEnvironment.shared.getSafeUserId())/preferences/\(preferenceId)", queryItems: nil) + try await get(path: "/users/\(getSafeUserId())/preferences/\(preferenceId)", queryItems: nil) } internal func setUserPreferences(preferenceId: String, preferenceSet: Knock.PreferenceSet) async throws -> Knock.PreferenceSet { - try await put(path: "/users/\(KnockEnvironment.shared.getSafeUserId())/preferences/\(preferenceId)", body: preferenceSet) + try await put(path: "/users/\(getSafeUserId())/preferences/\(preferenceId)", body: preferenceSet) } } diff --git a/Sources/Modules/Users/UserModule.swift b/Sources/Modules/Users/UserModule.swift index 841fb5d..1fe03b6 100644 --- a/Sources/Modules/Users/UserModule.swift +++ b/Sources/Modules/Users/UserModule.swift @@ -66,106 +66,3 @@ public extension Knock { } } } - - - -internal class AuthenticationModule { - let userService = UserService() - let channelService = ChannelService() - - private let logger: Logger = Logger(subsystem: Knock.loggingSubsytem, category: "Authentication") - - // AuthenticateUser - // logoutUser - - -// func authenticateUser(userId: String, userToken: String?) async throws { -// // TODO: remove previous userID and token. -// -// KnockEnvironment.shared.userId = userId -// KnockEnvironment.shared.userToken = userToken -// } - -// func authenticate(userId: String, userToken: String? = nil) { -// self.userId = userId -// self.api.userToken = userToken -// if let token = deviceToken, let channelId = pushChannelId { -// self.registerTokenForAPNS(channelId: channelId, token: token) { result in -// switch result { -// case .success(_): -// self.pushChannelId = pushChannelId -// self.userDeviceToken = deviceToken -// self.logger.debug("success registering the push token with Knock") -// completion(.success(())) -// case .failure(let error): -// self.logger.error("Error in registerTokenForAPNS: \(error.localizedDescription)") -// completion(.failure(error)) -// } -// } -// } -// } -} - -public extension Knock { - - func isAuthenticated(checkUserToken: Bool = false) -> Bool { - let isUser = KnockEnvironment.shared.userId?.isEmpty == false - if checkUserToken { - return isUser && KnockEnvironment.shared.userToken?.isEmpty == false - } - return isUser - } - - /** - Set the current credentials for the user and their access token. - Will also registerAPNS device token if set previously. - You should consider using this in areas where you update your local user's state - */ - func signIn(userId: String, userToken: String?) async throws { - KnockEnvironment.shared.setUserInfo(userId: userId, userToken: userToken) - - if let token = KnockEnvironment.shared.userDevicePushToken, let channelId = KnockEnvironment.shared.pushChannelId { - let _ = try await registerTokenForAPNS(channelId: channelId, token: token) - return - } - - return - } - - func signIn(userId: String, userToken: String?, completionHandler: @escaping ((Result) -> Void)) { - Task { - do { - try await signIn(userId: userId, userToken: userToken) - completionHandler(.success(())) - } catch { - completionHandler(.failure(error)) - } - } - } - - /** - Clears the current user id and access token - You should call this when your user signs out - It will remove the current tokens used for this user in Courier so they do not receive pushes they should not get - */ - func signOut() async throws { - guard let channelId = KnockEnvironment.shared.pushChannelId, let token = KnockEnvironment.shared.userDevicePushToken else { - self.resetInstance() - return - } - let _ = try await self.unregisterTokenForAPNS(channelId: channelId, token: token) - self.resetInstance() - return - } - - func signOut(completionHandler: @escaping ((Result) -> Void)) { - Task { - do { - try await signOut() - completionHandler(.success(())) - } catch { - completionHandler(.failure(error)) - } - } - } -} diff --git a/Sources/Modules/Users/UserService.swift b/Sources/Modules/Users/UserService.swift index a541ef0..df4ead9 100644 --- a/Sources/Modules/Users/UserService.swift +++ b/Sources/Modules/Users/UserService.swift @@ -10,10 +10,10 @@ import Foundation internal class UserService: KnockAPIService { internal func getUser() async throws -> Knock.User { - try await get(path: "/users/\(KnockEnvironment.shared.getSafeUserId())", queryItems: nil) + try await get(path: "/users/\(getSafeUserId())", queryItems: nil) } internal func updateUser(user: Knock.User) async throws -> Knock.User { - try await put(path: "/users/\(KnockEnvironment.shared.getSafeUserId())", body: user) + try await put(path: "/users/\(getSafeUserId())", body: user) } } From 25e51134caad7c2b97599f826e4a503a46a1b61f Mon Sep 17 00:00:00 2001 From: Matt Gardner Date: Mon, 5 Feb 2024 16:13:25 -0700 Subject: [PATCH 20/22] refactored logging --- Sources/Knock.swift | 53 ++++++----- Sources/KnockAPIService.swift | 28 +++--- Sources/KnockAppDelegate.swift | 88 ++++++++++--------- Sources/KnockEnvironment.swift | 44 +++++++--- Sources/KnockErrors.swift | 2 +- Sources/KnockLogger.swift | 23 ++++- Sources/Modules/Channels/ChannelModule.swift | 69 +++++++++++---- Sources/Modules/Channels/ChannelService.swift | 8 +- Sources/Modules/Feed/FeedManager.swift | 35 ++++++++ Sources/Modules/Feed/FeedModule.swift | 34 +++---- Sources/Modules/Feed/FeedService.swift | 4 +- Sources/Modules/Messages/MessageModule.swift | 16 ++-- .../Preferences/PreferenceModule.swift | 19 ++-- .../Preferences/PreferenceService.swift | 12 +-- Sources/Modules/Users/UserModule.swift | 11 +-- Sources/Modules/Users/UserService.swift | 55 +++++++++++- Tests/KnockTests/AuthenticationTests.swift | 36 ++++++++ Tests/KnockTests/File.swift | 71 +++++++++++++++ Tests/KnockTests/KnockTests.swift | 14 ++- 19 files changed, 452 insertions(+), 170 deletions(-) create mode 100644 Tests/KnockTests/AuthenticationTests.swift create mode 100644 Tests/KnockTests/File.swift diff --git a/Sources/Knock.swift b/Sources/Knock.swift index 15c29f0..9cdccf5 100644 --- a/Sources/Knock.swift +++ b/Sources/Knock.swift @@ -22,6 +22,7 @@ public class Knock { internal lazy var preferenceModule = PreferenceModule() internal lazy var messageModule = MessageModule() internal lazy var channelModule = ChannelModule() + internal lazy var logger = KnockLogger() /** Returns a new instance of the Knock Client @@ -30,36 +31,42 @@ public class Knock { - publishableKey: your public API key - options: [optional] Options for customizing the Knock instance. */ - public func setup(publishableKey: String, pushChannelId: String?, hostname: String? = nil) throws { + public func setup(publishableKey: String, pushChannelId: String?, options: Knock.KnockStartupOptions? = nil) throws { + logger.loggingDebugOptions = options?.debuggingType ?? .errorsOnly try environment.setPublishableKey(key: publishableKey) - environment.setBaseUrl(baseUrl: hostname) + environment.setBaseUrl(baseUrl: options?.hostname) environment.pushChannelId = pushChannelId } - -// @available(*, deprecated, message: "See v1.0.0 migration guide for more details.") -// public init(publishableKey: String, userId: String, userToken: String? = nil, hostname: String? = nil) throws { -// try KnockEnvironment.shared.setPublishableKey(key: publishableKey) -// KnockEnvironment.shared.setUserInfo(userId: userId, userToken: userToken) -// KnockEnvironment.shared.setBaseUrl(baseUrl: hostname) -// } - - internal func resetInstance() { + + public func resetInstance() async throws { + try await self.environment.resetInstance() self.feedManager = nil - environment.resetEnvironment() } } public extension Knock { - var userId: String? { - get { - return environment.userId - } - } + struct KnockStartupOptions { + public init(hostname: String? = nil, debuggingType: DebugOptions = .errorsOnly) { + self.hostname = hostname + self.debuggingType = debuggingType + } + var hostname: String? + var debuggingType: DebugOptions + } + + enum DebugOptions { + case errorsOnly + case verbose + case none + } } - - -// TODO: Possibly return user in authenticate method. -// TODO: Ensure threads are correct -// TODO: Handle AppDelegate -// TODO: Evaluate classes and determine if they should be structs +public extension Knock { + var userId: String? { + get { return environment.userId } + } + + var apnsDeviceToken: String? { + get { return environment.userId } + } +} diff --git a/Sources/KnockAPIService.swift b/Sources/KnockAPIService.swift index 1a3b405..6ba2f8f 100644 --- a/Sources/KnockAPIService.swift +++ b/Sources/KnockAPIService.swift @@ -8,15 +8,18 @@ import Foundation import OSLog -internal class KnockAPIService: NSObject { - private let apiVersion = "v1" +internal protocol KnockAPIService { + func get(path: String, queryItems: [URLQueryItem]?) async throws -> T + func put(path: String, body: Encodable?) async throws -> T + func post(path: String, body: Encodable?) async throws -> T + func delete(path: String, body: Encodable?) async throws -> T + func makeRequest(method: String, path: String, queryItems: [URLQueryItem]?, body: Encodable?) async throws -> T +} + +extension KnockAPIService { private var apiBaseUrl: String { return "\(Knock.shared.environment.baseUrl)/v1" } - - internal func getSafeUserId() throws -> String { - try Knock.shared.environment.getSafeUserId() - } func makeRequest(method: String, path: String, queryItems: [URLQueryItem]?, body: Encodable?) async throws -> T { let sessionConfig = URLSessionConfiguration.default @@ -26,7 +29,7 @@ internal class KnockAPIService: NSObject { guard var URL = URL(string: "\(apiBaseUrl)\(path)") else { let networkError = Knock.NetworkError(title: "Invalid URL", description: "The URL: \(apiBaseUrl)\(path) is invalid", code: 0) - KnockLogger.log(type: .warning, category: .networking, message: loggingMessageSummary, status: .fail, errorMessage: networkError.localizedDescription) + Knock.shared.log(type: .warning, category: .networking, message: loggingMessageSummary, status: .fail, errorMessage: networkError.localizedDescription) throw networkError } @@ -59,17 +62,14 @@ internal class KnockAPIService: NSObject { let statusCode = (urlResponse as! HTTPURLResponse).statusCode if statusCode < 200 || statusCode > 299 { let networkError = Knock.NetworkError(title: "Status code error", description: String(data: responseData, encoding: .utf8) ?? "Unknown error", code: statusCode) - KnockLogger.log(type: .warning, category: .networking, message: loggingMessageSummary, status: .fail, errorMessage: networkError.localizedDescription) + Knock.shared.log(type: .warning, category: .networking, message: loggingMessageSummary, status: .fail, errorMessage: networkError.localizedDescription) throw networkError } else { - KnockLogger.log(type: .debug, category: .networking, message: loggingMessageSummary, status: .success) + Knock.shared.log(type: .debug, category: .networking, message: loggingMessageSummary, status: .success) } return try decodeData(responseData) } -} - -extension KnockAPIService { internal func get(path: String, queryItems: [URLQueryItem]?) async throws -> T { try await makeRequest(method: "GET", path: path, queryItems: queryItems, body: nil) } @@ -103,10 +103,10 @@ extension KnockAPIService { } catch let error { if let dataString = String(data: data, encoding: .utf8) { let decodeError = Knock.KnockError.runtimeError("Error decoding data: \(dataString)") - KnockLogger.log(type: .error, category: .networking, message: "Error decoding data: \(dataString)", status: .fail, errorMessage: decodeError.localizedDescription) + Knock.shared.log(type: .error, category: .networking, message: "Error decoding data: \(dataString)", status: .fail, errorMessage: decodeError.localizedDescription) throw decodeError } else { - KnockLogger.log(type: .error, category: .networking, message: "Error processing undecodable data", status: .fail, errorMessage: error.localizedDescription) + Knock.shared.log(type: .error, category: .networking, message: "Error processing undecodable data", status: .fail, errorMessage: error.localizedDescription) throw error } } diff --git a/Sources/KnockAppDelegate.swift b/Sources/KnockAppDelegate.swift index 6159161..47a3df4 100644 --- a/Sources/KnockAppDelegate.swift +++ b/Sources/KnockAppDelegate.swift @@ -14,74 +14,76 @@ open class KnockAppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificat // MARK: Init - override init() { + public override init() { super.init() - - // Register to ensure device token can be fetched UIApplication.shared.registerForRemoteNotifications() UNUserNotificationCenter.current().delegate = self - } // MARK: Launching open func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool { + // Check if launched from a notification + if let launchOptions = launchOptions, + let userInfo = launchOptions[.remoteNotification] as? [String: AnyObject] { + Knock.shared.log(type: .error, category: .pushNotification, message: "pushNotificationTapped") + pushNotificationTapped(userInfo: userInfo) + } + return true } - // MARK: Notifications + // MARK: Token Management - open func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { - KnockLogger.log(type: .debug, category: .appDelegate, message: "userNotificationCenter willPresent notification", description: "\(notification)") - - let userInfo = notification.request.content.userInfo - - let presentationOptions = pushNotificationDeliveredInForeground(userInfo: userInfo) - completionHandler(presentationOptions) + open func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) { + Knock.shared.log(type: .error, category: .pushNotification, message: "Failed to register for notifications", errorMessage: error.localizedDescription) } - open func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { - KnockLogger.log(type: .debug, category: .appDelegate, message: "didReceiveNotificationResponse", description: "\(response)") + open func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { + Task { + let channelId = Knock.shared.environment.pushChannelId - let userInfo = response.notification.request.content.userInfo - - if response.actionIdentifier == UNNotificationDismissActionIdentifier { - pushNotificationDismissed(userInfo: userInfo) - } else { - pushNotificationTapped(userInfo: userInfo) + do { + if let id = channelId { + let _ = try await Knock.shared.registerTokenForAPNS(channelId: id, token: deviceToken) + } else { + Knock.shared.log(type: .error, category: .pushNotification, message: "didRegisterForRemoteNotificationsWithDeviceToken", status: .fail, errorMessage: "Unable to find pushChannelId. Please set the pushChannelId with Knock.shared.setup") + } + } catch let error { + Knock.shared.log(type: .error, category: .pushNotification, message: "didRegisterForRemoteNotificationsWithDeviceToken", description: "Unable to register for push notification at this time", status: .fail, errorMessage: error.localizedDescription) + } } - completionHandler() } - // MARK: Token Management + // MARK: Notifications - open func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) { - KnockLogger.log(type: .error, category: .appDelegate, message: "Failed to register for notifications", description: error.localizedDescription) + open func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { + Knock.shared.log(type: .debug, category: .pushNotification, message: "pushNotificationDeliveredInForeground") + let presentationOptions = pushNotificationDeliveredInForeground(notification: notification) + completionHandler(presentationOptions) } - open func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { - // 1. Convert device token to string - let tokenParts = deviceToken.map { data -> String in - return String(format: "%02.2hhx", data) - } - let token = tokenParts.joined() - // 2. Print device token to use for PNs payloads - Knock.shared.registerTokenForAPNS(channelId: <#T##String#>, token: <#T##String#>, completionHandler: <#T##((Result) -> Void)##((Result) -> Void)##(Result) -> Void#>) - KnockLogger.log(type: .debug, category: .appDelegate, message: "Successfully registered for notifications!", description: "Device Token: \(token)") - - let defaults = UserDefaults.standard - defaults.set(token, forKey: "device_push_token") -// deviceTokenDidChange(apnsToken: token, isDebugging: isDebuggerAttached) -// self.pushToken = token + open func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { + Knock.shared.log(type: .debug, category: .pushNotification, message: "pushNotificationTapped") + pushNotificationTapped(userInfo: response.notification.request.content.userInfo) + completionHandler() } - // MARK: Functions + open func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable : Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) { + Knock.shared.log(type: .debug, category: .pushNotification, message: "pushNotificationDeliveredSilently") + pushNotificationDeliveredSilently(userInfo: userInfo, completionHandler: completionHandler) + } - open func deviceTokenDidChange(apnsToken: String, isDebugging: Bool) {} + // MARK: Convenience methods to make handling incoming push notifications simpler. + open func deviceTokenDidChange(apnsToken: String) {} - open func pushNotificationDeliveredInForeground(userInfo: [AnyHashable : Any]) -> UNNotificationPresentationOptions { return [] } + open func pushNotificationDeliveredInForeground(notification: UNNotification) -> UNNotificationPresentationOptions { + return [.sound, .badge, .banner] + } open func pushNotificationTapped(userInfo: [AnyHashable : Any]) {} - - open func pushNotificationDismissed(userInfo: [AnyHashable : Any]) {} + + open func pushNotificationDeliveredSilently(userInfo: [AnyHashable : Any], completionHandler: @escaping (UIBackgroundFetchResult) -> Void) { + completionHandler(.noData) + } } diff --git a/Sources/KnockEnvironment.swift b/Sources/KnockEnvironment.swift index 2924bd0..5ead986 100644 --- a/Sources/KnockEnvironment.swift +++ b/Sources/KnockEnvironment.swift @@ -9,28 +9,49 @@ import Foundation internal class KnockEnvironment { static let defaultBaseUrl: String = "https://api.knock.app" - + private let defaults = UserDefaults.standard + private let userDevicePushTokenKey = "knock_push_device_token" + private let pushChannelIdKey = "knock_push_channel_id" + private(set) var userId: String? private(set) var userToken: String? - internal(set) var userDevicePushToken: String? - internal(set) var pushChannelId: String? private(set) var publishableKey: String? private(set) var baseUrl: String = defaultBaseUrl + + internal func resetInstance() async throws { + try await Knock.shared.authenticationModule.signOut() + setBaseUrl(baseUrl: nil) + publishableKey = nil + pushChannelId = nil + } + + var userDevicePushToken: String? { + get { + defaults.string(forKey: userDevicePushTokenKey) + } + set { + defaults.set(newValue, forKey: userDevicePushTokenKey) + } + } + + var pushChannelId: String? { + get { + defaults.string(forKey: pushChannelIdKey) + } + set { + defaults.set(newValue, forKey: pushChannelIdKey) + } + } func setPublishableKey(key: String) throws { guard key.hasPrefix("sk_") == false else { let error = Knock.KnockError.publishableKeyError("You are using your secret API key on the client. Please use the public key.") - KnockLogger.log(type: .error, category: .general, message: "setPublishableKey", status: .fail, errorMessage: error.localizedDescription) + Knock.shared.log(type: .error, category: .general, message: "setPublishableKey", status: .fail, errorMessage: error.localizedDescription) throw error } self.publishableKey = key } -// func setPushInformation(channelId: String?, deviceToken: String?) { -// self.pushChannelId = channelId -// self.userDevicePushToken = deviceToken -// } - func setUserInfo(userId: String?, userToken: String?) { self.userId = userId self.userToken = userToken @@ -40,11 +61,6 @@ internal class KnockEnvironment { self.baseUrl = "\(baseUrl ?? KnockEnvironment.defaultBaseUrl)" } - func resetEnvironment() { - setUserInfo(userId: nil, userToken: nil) - setPushInformation(channelId: nil, deviceToken: nil) - } - func getSafeUserId() throws -> String { guard let id = userId else { throw Knock.KnockError.userIdError diff --git a/Sources/KnockErrors.swift b/Sources/KnockErrors.swift index 4a42cb9..6c1c203 100644 --- a/Sources/KnockErrors.swift +++ b/Sources/KnockErrors.swift @@ -36,7 +36,7 @@ extension Knock.KnockError: LocalizedError { case .runtimeError(let message): return message case .userIdError: - return "UserId not found. Please authenticate your userId with Knock.authenticate()." + return "UserId not found. Please authenticate your userId with Knock.signIn()." case .publishableKeyError(let message): return message } diff --git a/Sources/KnockLogger.swift b/Sources/KnockLogger.swift index a4f8738..5f5ea72 100644 --- a/Sources/KnockLogger.swift +++ b/Sources/KnockLogger.swift @@ -10,8 +10,21 @@ import os.log internal class KnockLogger { private static let loggingSubsytem = "knock-swift" + + internal var loggingDebugOptions: Knock.DebugOptions = .errorsOnly - internal static func log(type: LogType, category: LogCategory, message: String, description: String? = nil, status: LogStatus? = nil, errorMessage: String? = nil, additionalInfo: [String: String]? = nil) { + internal func log(type: LogType, category: LogCategory, message: String, description: String? = nil, status: LogStatus? = nil, errorMessage: String? = nil, additionalInfo: [String: String]? = nil) { + switch loggingDebugOptions { + case .errorsOnly: + if type != .error { + return + } + case .verbose: + break + case .none: + return + } + var composedMessage = "[Knock] " composedMessage += message if let description = description { @@ -30,7 +43,7 @@ internal class KnockLogger { } // Use the Logger API for logging - let logger = Logger(subsystem: loggingSubsytem, category: category.rawValue.capitalized) + let logger = Logger(subsystem: KnockLogger.loggingSubsytem, category: category.rawValue.capitalized) switch type { case .debug: logger.debug("\(composedMessage)") @@ -70,3 +83,9 @@ internal class KnockLogger { case appDelegate } } + +extension Knock { + internal func log(type: KnockLogger.LogType, category: KnockLogger.LogCategory, message: String, description: String? = nil, status: KnockLogger.LogStatus? = nil, errorMessage: String? = nil, additionalInfo: [String: String]? = nil) { + Knock.shared.logger.log(type: type, category: category, message: message, description: description, status: status, errorMessage: errorMessage, additionalInfo: additionalInfo) + } +} diff --git a/Sources/Modules/Channels/ChannelModule.swift b/Sources/Modules/Channels/ChannelModule.swift index a2f87a8..a86b58e 100644 --- a/Sources/Modules/Channels/ChannelModule.swift +++ b/Sources/Modules/Channels/ChannelModule.swift @@ -7,28 +7,32 @@ import Foundation import OSLog +import UIKit internal class ChannelModule { let channelService = ChannelService() + + internal var userNotificationCenter: UNUserNotificationCenter { + get { UNUserNotificationCenter.current() } + } func getUserChannelData(channelId: String) async throws -> Knock.ChannelData { do { - let data = try await channelService.getUserChannelData(channelId: channelId) - KnockLogger.log(type: .debug, category: .channel, message: "getUserChannelData", status: .success) + let data = try await channelService.getUserChannelData(userId: Knock.shared.environment.getSafeUserId(), channelId: channelId) return data } catch let error { - KnockLogger.log(type: .warning, category: .channel, message: "getUserChannelData", status: .fail, errorMessage: error.localizedDescription) + Knock.shared.log(type: .warning, category: .channel, message: "getUserChannelData", status: .fail, errorMessage: error.localizedDescription) throw error } } func updateUserChannelData(channelId: String, data: AnyEncodable) async throws -> Knock.ChannelData { do { - let data = try await channelService.updateUserChannelData(channelId: channelId, data: data) - KnockLogger.log(type: .debug, category: .channel, message: "updateUserChannelData", status: .success) + let data = try await channelService.updateUserChannelData(userId: Knock.shared.environment.getSafeUserId(), channelId: channelId, data: data) + Knock.shared.log(type: .debug, category: .channel, message: "updateUserChannelData", status: .success) return data } catch let error { - KnockLogger.log(type: .warning, category: .channel, message: "updateUserChannelData", status: .fail, errorMessage: error.localizedDescription) + Knock.shared.log(type: .warning, category: .channel, message: "updateUserChannelData", status: .fail, errorMessage: error.localizedDescription) throw error } } @@ -41,12 +45,13 @@ internal class ChannelModule { let data: AnyEncodable = ["tokens": tokens] let channelData = try await updateUserChannelData(channelId: channelId, data: data) - KnockLogger.log(type: .debug, category: .pushNotification, message: "registerOrUpdateToken", status: .success) + Knock.shared.log(type: .debug, category: .pushNotification, message: "registerOrUpdateToken", status: .success) return channelData } func registerTokenForAPNS(channelId: String, token: String) async throws -> Knock.ChannelData { - Knock.shared.environment.setPushInformation(channelId: channelId, deviceToken: token) + Knock.shared.environment.pushChannelId = channelId + Knock.shared.environment.userDevicePushToken = token do { let channelData = try await getUserChannelData(channelId: channelId) @@ -57,13 +62,14 @@ internal class ChannelModule { if tokens.contains(token) { // Token already registered + Knock.shared.log(type: .debug, category: .pushNotification, message: "registerTokenForAPNS", status: .success) return channelData } else { // Register the new token return try await registerOrUpdateToken(token: token, channelId: channelId, existingTokens: tokens) } } catch let userIdError as Knock.KnockError where userIdError == Knock.KnockError.userIdError { - KnockLogger.log(type: .warning, category: .pushNotification, message: "ChannelId and deviceToken were saved. However, we cannot register for APNS until you have have called Knock.signIn().") + Knock.shared.log(type: .warning, category: .pushNotification, message: "ChannelId and deviceToken were saved. However, we cannot register for APNS until you have have called Knock.signIn().") throw userIdError } catch { // No data registered on that channel for that user, we'll create a new record @@ -76,7 +82,7 @@ internal class ChannelModule { let channelData = try await getUserChannelData(channelId: channelId) guard let data = channelData.data, let tokens = data["tokens"]?.value as? [String] else { // No valid tokens array found. - KnockLogger.log(type: .warning, category: .pushNotification, message: "unregisterTokenForAPNS", description: "Could not unregister user from channel \(channelId). Reason: User doesn't have any device tokens associated to the provided channelId.") + Knock.shared.log(type: .warning, category: .pushNotification, message: "unregisterTokenForAPNS", description: "Could not unregister user from channel \(channelId). Reason: User doesn't have any device tokens associated to the provided channelId.") return channelData } @@ -87,20 +93,20 @@ internal class ChannelModule { "tokens": newTokens ] let updateData = try await updateUserChannelData(channelId: channelId, data: data) - KnockLogger.log(type: .debug, category: .pushNotification, message: "unregisterTokenForAPNS", status: .success) + Knock.shared.log(type: .debug, category: .pushNotification, message: "unregisterTokenForAPNS", status: .success) return updateData } else { - KnockLogger.log(type: .warning, category: .pushNotification, message: "unregisterTokenForAPNS", description: "Could not unregister user from channel \(channelId). Reason: User doesn't have any device tokens associated to the provided channelId.") + Knock.shared.log(type: .warning, category: .pushNotification, message: "unregisterTokenForAPNS", description: "Could not unregister user from channel \(channelId). Reason: User doesn't have any device tokens associated to the provided channelId.") return channelData } } catch { if let networkError = error as? Knock.NetworkError, networkError.code == 404 { // No data registered on that channel for that user - KnockLogger.log(type: .warning, category: .pushNotification, message: "unregisterTokenForAPNS", description: "Could not unregister user from channel \(channelId). Reason: User doesn't have any channel data associated to the provided channelId.") + Knock.shared.log(type: .warning, category: .pushNotification, message: "unregisterTokenForAPNS", description: "Could not unregister user from channel \(channelId). Reason: User doesn't have any channel data associated to the provided channelId.") return .init(channel_id: channelId, data: [:]) } else { // Unknown error. Could be network or server related. Try again. - KnockLogger.log(type: .error, category: .pushNotification, message: "unregisterTokenForAPNS", description: "Could not unregister user from channel \(channelId)", status: .fail, errorMessage: error.localizedDescription) + Knock.shared.log(type: .error, category: .pushNotification, message: "unregisterTokenForAPNS", description: "Could not unregister user from channel \(channelId)", status: .fail, errorMessage: error.localizedDescription) throw error } } @@ -110,7 +116,13 @@ internal class ChannelModule { public extension Knock { func getUserChannelData(channelId: String) async throws -> ChannelData { - try await self.channelModule.getUserChannelData(channelId: channelId) + do { + try await self.channelModule.getUserChannelData(channelId: channelId) + Knock.shared.log(type: .debug, category: .channel, message: "getUserChannelData", status: .success) + } catch let error { + Knock.shared.log(type: .warning, category: .channel, message: "getUserChannelData", status: .fail, errorMessage: error.localizedDescription) + throw error + } } func getUserChannelData(channelId: String, completionHandler: @escaping ((Result) -> Void)) { @@ -232,4 +244,31 @@ public extension Knock { let tokenString = Knock.convertTokenToString(token: token) unregisterTokenForAPNS(channelId: channelId, token: tokenString, completionHandler: completionHandler) } + + func getNotificationPermissionStatus(completion: @escaping (UNAuthorizationStatus) -> Void) { + channelModule.userNotificationCenter.getNotificationSettings(completionHandler: { settings in + completion(settings.authorizationStatus) + }) + } + + func getNotificationPermissionStatus() async -> UNAuthorizationStatus { + let settings = await channelModule.userNotificationCenter.notificationSettings() + return settings.authorizationStatus + } + + func requestNotificationPermission(options: UNAuthorizationOptions = [.sound, .badge, .alert], completion: @escaping (UNAuthorizationStatus) -> Void) { + channelModule.userNotificationCenter.requestAuthorization( + options: options, + completionHandler: { _, _ in + self.getNotificationPermissionStatus { permission in + completion(permission) + } + } + ) + } + + func requestNotificationPermission(options: UNAuthorizationOptions = [.sound, .badge, .alert]) async throws -> UNAuthorizationStatus { + try await channelModule.userNotificationCenter.requestAuthorization(options: options) + return await getNotificationPermissionStatus() + } } diff --git a/Sources/Modules/Channels/ChannelService.swift b/Sources/Modules/Channels/ChannelService.swift index 750dd19..0eca6a0 100644 --- a/Sources/Modules/Channels/ChannelService.swift +++ b/Sources/Modules/Channels/ChannelService.swift @@ -10,12 +10,12 @@ import OSLog internal class ChannelService: KnockAPIService { - func getUserChannelData(channelId: String) async throws -> Knock.ChannelData { - try await get(path: "/users/\(getSafeUserId())/channel_data/\(channelId)", queryItems: nil) + func getUserChannelData(userId: String, channelId: String) async throws -> Knock.ChannelData { + try await get(path: "/users/\(userId)/channel_data/\(channelId)", queryItems: nil) } - func updateUserChannelData(channelId: String, data: AnyEncodable) async throws -> Knock.ChannelData { + func updateUserChannelData(userId: String, channelId: String, data: AnyEncodable) async throws -> Knock.ChannelData { let body = ["data": data] - return try await put(path: "/users/\(getSafeUserId())/channel_data/\(channelId)", body: body) + return try await put(path: "/users/\(userId)/channel_data/\(channelId)", body: body) } } diff --git a/Sources/Modules/Feed/FeedManager.swift b/Sources/Modules/Feed/FeedManager.swift index 364622c..84a56ac 100644 --- a/Sources/Modules/Feed/FeedManager.swift +++ b/Sources/Modules/Feed/FeedManager.swift @@ -8,14 +8,41 @@ import Foundation import SwiftPhoenixClient import OSLog +import UIKit public extension Knock { class FeedManager { private let feedModule: FeedModule + private var foregroundObserver: NSObjectProtocol? + private var backgroundObserver: NSObjectProtocol? public init(feedId: String, options: FeedClientOptions = FeedClientOptions(archived: .exclude)) throws { self.feedModule = try FeedModule(feedId: feedId, options: options) + registerForAppLifecycleNotifications() + } + + deinit { + unregisterFromAppLifecycleNotifications() + } + + private func registerForAppLifecycleNotifications() { + foregroundObserver = NotificationCenter.default.addObserver(forName: UIApplication.didBecomeActiveNotification, object: nil, queue: .main) { [weak self] _ in + self?.didEnterForeground() + } + + backgroundObserver = NotificationCenter.default.addObserver(forName: UIApplication.didEnterBackgroundNotification, object: nil, queue: .main) { [weak self] _ in + self?.didEnterBackground() + } + } + + private func unregisterFromAppLifecycleNotifications() { + if let observer = foregroundObserver { + NotificationCenter.default.removeObserver(observer) + } + if let observer = backgroundObserver { + NotificationCenter.default.removeObserver(observer) + } } /** @@ -82,5 +109,13 @@ public extension Knock { } } } + + public func didEnterForeground() { + Knock.shared.feedManager?.connectToFeed() + } + + public func didEnterBackground() { + Knock.shared.feedManager?.disconnectFromFeed() + } } } diff --git a/Sources/Modules/Feed/FeedModule.swift b/Sources/Modules/Feed/FeedModule.swift index ee3b76e..d850403 100644 --- a/Sources/Modules/Feed/FeedModule.swift +++ b/Sources/Modules/Feed/FeedModule.swift @@ -25,7 +25,7 @@ internal class FeedModule { do { userId = try Knock.shared.environment.getSafeUserId() } catch let error { - KnockLogger.log(type: .error, category: .feed, message: "FeedManager", status: .fail, errorMessage: "Must sign user in before initializing the FeedManager") + Knock.shared.log(type: .error, category: .feed, message: "FeedManager", status: .fail, errorMessage: "Must sign user in before initializing the FeedManager") throw error } @@ -33,7 +33,7 @@ internal class FeedModule { self.feedId = feedId self.feedTopic = "feeds:\(feedId):\(userId)" self.feedOptions = options - KnockLogger.log(type: .debug, category: .feed, message: "FeedManager", status: .success) + Knock.shared.log(type: .debug, category: .feed, message: "FeedManager", status: .success) } func getUserFeedContent(options: Knock.FeedClientOptions? = nil) async throws -> Knock.Feed { @@ -54,11 +54,11 @@ internal class FeedModule { ] do { - let feed = try await feedService.getUserFeedContent(queryItems: queryItems, feedId: feedId) - KnockLogger.log(type: .debug, category: .feed, message: "getUserFeedContent", status: .success) + let feed = try await feedService.getUserFeedContent(userId: Knock.shared.environment.getSafeUserId(), queryItems: queryItems, feedId: feedId) + Knock.shared.log(type: .debug, category: .feed, message: "getUserFeedContent", status: .success) return feed } catch let error { - KnockLogger.log(type: .error, category: .feed, message: "getUserFeedContent", status: .fail, errorMessage: error.localizedDescription) + Knock.shared.log(type: .error, category: .feed, message: "getUserFeedContent", status: .fail, errorMessage: error.localizedDescription) throw error } } @@ -80,16 +80,16 @@ internal class FeedModule { ] do { let op = try await feedService.makeBulkStatusUpdate(feedId: feedId, type: type, body: body) - KnockLogger.log(type: .debug, category: .feed, message: "makeBulkStatusUpdate", status: .success) + Knock.shared.log(type: .debug, category: .feed, message: "makeBulkStatusUpdate", status: .success) return op } catch let error { - KnockLogger.log(type: .error, category: .feed, message: "makeBulkStatusUpdate", status: .fail, errorMessage: error.localizedDescription) + Knock.shared.log(type: .error, category: .feed, message: "makeBulkStatusUpdate", status: .fail, errorMessage: error.localizedDescription) throw error } } func disconnectFromFeed() { - KnockLogger.log(type: .debug, category: .feed, message: "Disconnecting from feed") + Knock.shared.log(type: .debug, category: .feed, message: "Disconnecting from feed") if let channel = self.feedChannel { channel.leave() @@ -99,7 +99,7 @@ internal class FeedModule { self.socket.disconnect() } - // Todo: Make async await method for this + // Todo: Make AsyncStream method for this func on(eventName: String, completionHandler: @escaping ((Message) -> Void)) { if let channel = feedChannel { channel.delegateOn(eventName, to: self) { (self, message) in @@ -107,33 +107,33 @@ internal class FeedModule { } } else { - KnockLogger.log(type: .error, category: .feed, message: "FeedManager.on", status: .fail, errorMessage: "Feed channel is nil. You should call first connectToFeed()") + Knock.shared.log(type: .error, category: .feed, message: "FeedManager.on", status: .fail, errorMessage: "Feed channel is nil. You should call first connectToFeed()") } } func connectToFeed(options: Knock.FeedClientOptions? = nil) { // Setup the socket to receive open/close events socket.delegateOnOpen(to: self) { (self) in - KnockLogger.log(type: .debug, category: .feed, message: "connectToFeed", description: "Socket Opened") + Knock.shared.log(type: .debug, category: .feed, message: "connectToFeed", description: "Socket Opened") } socket.delegateOnClose(to: self) { (self) in - KnockLogger.log(type: .debug, category: .feed, message: "connectToFeed", description: "Socket Closed") + Knock.shared.log(type: .debug, category: .feed, message: "connectToFeed", description: "Socket Closed") } socket.delegateOnError(to: self) { (self, error) in let (error, response) = error if let statusCode = (response as? HTTPURLResponse)?.statusCode, statusCode > 400 { - KnockLogger.log(type: .error, category: .feed, message: "connectToFeed", description: "Socket Errored \(statusCode)", status: .fail, errorMessage: error.localizedDescription) + Knock.shared.log(type: .error, category: .feed, message: "connectToFeed", description: "Socket Errored \(statusCode)", status: .fail, errorMessage: error.localizedDescription) self.socket.disconnect() } else { - KnockLogger.log(type: .error, category: .feed, message: "connectToFeed", description: "Socket Errored", status: .fail, errorMessage: error.localizedDescription) + Knock.shared.log(type: .error, category: .feed, message: "connectToFeed", description: "Socket Errored", status: .fail, errorMessage: error.localizedDescription) } } // TODO: Determine the level of logging we want from SwiftPhoenixClient. Currently this produces a lot of noise. socket.logger = { msg in - KnockLogger.log(type: .debug, category: .feed, message: "SwiftPhoenixClient", description: msg) + Knock.shared.log(type: .debug, category: .feed, message: "SwiftPhoenixClient", description: msg) } let mergedOptions = feedOptions.mergeOptions(options: options) @@ -148,10 +148,10 @@ internal class FeedModule { self.feedChannel? .join() .delegateReceive("ok", to: self) { (self, _) in - KnockLogger.log(type: .debug, category: .feed, message: "connectToFeed", description: "CHANNEL: \(channel.topic) joined") + Knock.shared.log(type: .debug, category: .feed, message: "connectToFeed", description: "CHANNEL: \(channel.topic) joined") } .delegateReceive("error", to: self) { (self, message) in - KnockLogger.log(type: .error, category: .feed, message: "connectToFeed", status: .fail, errorMessage: "CHANNEL: \(channel.topic) failed to join. \(message.payload)") + Knock.shared.log(type: .error, category: .feed, message: "connectToFeed", status: .fail, errorMessage: "CHANNEL: \(channel.topic) failed to join. \(message.payload)") } self.socket.connect() diff --git a/Sources/Modules/Feed/FeedService.swift b/Sources/Modules/Feed/FeedService.swift index 0c94f40..47e38e3 100644 --- a/Sources/Modules/Feed/FeedService.swift +++ b/Sources/Modules/Feed/FeedService.swift @@ -8,8 +8,8 @@ import Foundation internal class FeedService: KnockAPIService { - func getUserFeedContent(queryItems: [URLQueryItem]?, feedId: String) async throws -> Knock.Feed { - try await get(path: "/users/\(getSafeUserId())/feeds/\(feedId)", queryItems: queryItems) + func getUserFeedContent(userId: String, queryItems: [URLQueryItem]?, feedId: String) async throws -> Knock.Feed { + try await get(path: "/users/\(userId)/feeds/\(feedId)", queryItems: queryItems) } func makeBulkStatusUpdate(feedId: String, type: Knock.BulkChannelMessageStatusUpdateType, body: AnyEncodable?) async throws -> Knock.BulkOperation { diff --git a/Sources/Modules/Messages/MessageModule.swift b/Sources/Modules/Messages/MessageModule.swift index c47c078..7b19c88 100644 --- a/Sources/Modules/Messages/MessageModule.swift +++ b/Sources/Modules/Messages/MessageModule.swift @@ -13,10 +13,10 @@ internal class MessageModule { internal func getMessage(messageId: String) async throws -> Knock.KnockMessage { do { let message = try await messageService.getMessage(messageId: messageId) - KnockLogger.log(type: .debug, category: .message, message: "getMessage", status: .success, additionalInfo: ["messageId": messageId]) + Knock.shared.log(type: .debug, category: .message, message: "getMessage", status: .success, additionalInfo: ["messageId": messageId]) return message } catch let error { - KnockLogger.log(type: .error, category: .message, message: "getMessage", status: .fail, errorMessage: error.localizedDescription, additionalInfo: ["messageId": messageId]) + Knock.shared.log(type: .error, category: .message, message: "getMessage", status: .fail, errorMessage: error.localizedDescription, additionalInfo: ["messageId": messageId]) throw error } } @@ -24,10 +24,10 @@ internal class MessageModule { internal func updateMessageStatus(messageId: String, status: Knock.KnockMessageStatusUpdateType) async throws -> Knock.KnockMessage { do { let message = try await messageService.updateMessageStatus(messageId: messageId, status: status) - KnockLogger.log(type: .debug, category: .message, message: "updateMessageStatus", status: .success, additionalInfo: ["messageId": messageId]) + Knock.shared.log(type: .debug, category: .message, message: "updateMessageStatus", status: .success, additionalInfo: ["messageId": messageId]) return message } catch let error { - KnockLogger.log(type: .error, category: .message, message: "updateMessageStatus", status: .fail, errorMessage: error.localizedDescription, additionalInfo: ["messageId": messageId]) + Knock.shared.log(type: .error, category: .message, message: "updateMessageStatus", status: .fail, errorMessage: error.localizedDescription, additionalInfo: ["messageId": messageId]) throw error } } @@ -35,10 +35,10 @@ internal class MessageModule { internal func deleteMessageStatus(messageId: String, status: Knock.KnockMessageStatusUpdateType) async throws -> Knock.KnockMessage { do { let message = try await messageService.deleteMessageStatus(messageId: messageId, status: status) - KnockLogger.log(type: .debug, category: .message, message: "deleteMessageStatus", status: .success, additionalInfo: ["messageId": messageId]) + Knock.shared.log(type: .debug, category: .message, message: "deleteMessageStatus", status: .success, additionalInfo: ["messageId": messageId]) return message } catch let error { - KnockLogger.log(type: .error, category: .message, message: "deleteMessageStatus", status: .fail, errorMessage: error.localizedDescription, additionalInfo: ["messageId": messageId]) + Knock.shared.log(type: .error, category: .message, message: "deleteMessageStatus", status: .fail, errorMessage: error.localizedDescription, additionalInfo: ["messageId": messageId]) throw error } } @@ -46,10 +46,10 @@ internal class MessageModule { internal func batchUpdateStatuses(messageIds: [String], status: Knock.KnockMessageStatusBatchUpdateType) async throws -> [Knock.KnockMessage] { do { let messages = try await messageService.batchUpdateStatuses(messageIds: messageIds, status: status) - KnockLogger.log(type: .debug, category: .message, message: "batchUpdateStatuses", status: .success) + Knock.shared.log(type: .debug, category: .message, message: "batchUpdateStatuses", status: .success) return messages } catch let error { - KnockLogger.log(type: .error, category: .message, message: "batchUpdateStatuses", status: .fail, errorMessage: error.localizedDescription) + Knock.shared.log(type: .error, category: .message, message: "batchUpdateStatuses", status: .fail, errorMessage: error.localizedDescription) throw error } } diff --git a/Sources/Modules/Preferences/PreferenceModule.swift b/Sources/Modules/Preferences/PreferenceModule.swift index 16dc08b..9cc0c1e 100644 --- a/Sources/Modules/Preferences/PreferenceModule.swift +++ b/Sources/Modules/Preferences/PreferenceModule.swift @@ -13,33 +13,33 @@ internal class PreferenceModule { internal func getAllUserPreferences() async throws -> [Knock.PreferenceSet] { do { - let set = try await preferenceService.getAllUserPreferences() - KnockLogger.log(type: .debug, category: .preferences, message: "getAllUserPreferences", status: .success) + let set = try await preferenceService.getAllUserPreferences(userId: Knock.shared.environment.getSafeUserId()) + Knock.shared.log(type: .debug, category: .preferences, message: "getAllUserPreferences", status: .success) return set } catch let error { - KnockLogger.log(type: .error, category: .preferences, message: "getAllUserPreferences", status: .fail, errorMessage: error.localizedDescription) + Knock.shared.log(type: .error, category: .preferences, message: "getAllUserPreferences", status: .fail, errorMessage: error.localizedDescription) throw error } } internal func getUserPreferences(preferenceId: String) async throws -> Knock.PreferenceSet { do { - let set = try await preferenceService.getUserPreferences(preferenceId: preferenceId) - KnockLogger.log(type: .debug, category: .preferences, message: "getUserPreferences", status: .success) + let set = try await preferenceService.getUserPreferences(userId: Knock.shared.environment.getSafeUserId(), preferenceId: preferenceId) + Knock.shared.log(type: .debug, category: .preferences, message: "getUserPreferences", status: .success) return set } catch let error { - KnockLogger.log(type: .error, category: .preferences, message: "getUserPreferences", status: .fail, errorMessage: error.localizedDescription) + Knock.shared.log(type: .error, category: .preferences, message: "getUserPreferences", status: .fail, errorMessage: error.localizedDescription) throw error } } internal func setUserPreferences(preferenceId: String, preferenceSet: Knock.PreferenceSet) async throws -> Knock.PreferenceSet { do { - let set = try await preferenceService.setUserPreferences(preferenceId: preferenceId, preferenceSet: preferenceSet) - KnockLogger.log(type: .debug, category: .preferences, message: "setUserPreferences", status: .success) + let set = try await preferenceService.setUserPreferences(userId: Knock.shared.environment.getSafeUserId(), preferenceId: preferenceId, preferenceSet: preferenceSet) + Knock.shared.log(type: .debug, category: .preferences, message: "setUserPreferences", status: .success) return set } catch let error { - KnockLogger.log(type: .error, category: .preferences, message: "setUserPreferences", status: .fail, errorMessage: error.localizedDescription) + Knock.shared.log(type: .error, category: .preferences, message: "setUserPreferences", status: .fail, errorMessage: error.localizedDescription) throw error } } @@ -65,7 +65,6 @@ public extension Knock { try await self.preferenceModule.getUserPreferences(preferenceId: preferenceId) } - @MainActor func getUserPreferences(preferenceId: String, completionHandler: @escaping ((Result) -> Void)) { Task { do { diff --git a/Sources/Modules/Preferences/PreferenceService.swift b/Sources/Modules/Preferences/PreferenceService.swift index 083ef65..c20d9cd 100644 --- a/Sources/Modules/Preferences/PreferenceService.swift +++ b/Sources/Modules/Preferences/PreferenceService.swift @@ -9,15 +9,15 @@ import Foundation internal class PreferenceService: KnockAPIService { - internal func getAllUserPreferences() async throws -> [Knock.PreferenceSet] { - try await get(path: "/users/\(getSafeUserId())/preferences", queryItems: nil) + internal func getAllUserPreferences(userId: String) async throws -> [Knock.PreferenceSet] { + try await get(path: "/users/\(userId)/preferences", queryItems: nil) } - internal func getUserPreferences(preferenceId: String) async throws -> Knock.PreferenceSet { - try await get(path: "/users/\(getSafeUserId())/preferences/\(preferenceId)", queryItems: nil) + internal func getUserPreferences(userId: String, preferenceId: String) async throws -> Knock.PreferenceSet { + try await get(path: "/users/\(userId)/preferences/\(preferenceId)", queryItems: nil) } - internal func setUserPreferences(preferenceId: String, preferenceSet: Knock.PreferenceSet) async throws -> Knock.PreferenceSet { - try await put(path: "/users/\(getSafeUserId())/preferences/\(preferenceId)", body: preferenceSet) + internal func setUserPreferences(userId: String, preferenceId: String, preferenceSet: Knock.PreferenceSet) async throws -> Knock.PreferenceSet { + try await put(path: "/users/\(userId)/preferences/\(preferenceId)", body: preferenceSet) } } diff --git a/Sources/Modules/Users/UserModule.swift b/Sources/Modules/Users/UserModule.swift index 1fe03b6..ff06b1a 100644 --- a/Sources/Modules/Users/UserModule.swift +++ b/Sources/Modules/Users/UserModule.swift @@ -13,11 +13,11 @@ internal class UserModule { func getUser() async throws -> Knock.User { do { - let user = try await userService.getUser() - KnockLogger.log(type: .debug, category: .user, message: "getUser", status: .success) + let user = try await userService.getUser(userId: Knock.shared.environment.getSafeUserId()) + Knock.shared.log(type: .debug, category: .user, message: "getUser", status: .success) return user } catch let error { - KnockLogger.log(type: .error, category: .user, message: "getUser", status: .fail, errorMessage: error.localizedDescription) + Knock.shared.log(type: .error, category: .user, message: "getUser", status: .fail, errorMessage: error.localizedDescription) throw error } } @@ -25,10 +25,10 @@ internal class UserModule { func updateUser(user: Knock.User) async throws -> Knock.User { do { let user = try await userService.updateUser(user: user) - KnockLogger.log(type: .debug, category: .user, message: "updateUser", status: .success) + Knock.shared.log(type: .debug, category: .user, message: "updateUser", status: .success) return user } catch let error { - KnockLogger.log(type: .error, category: .user, message: "updateUser", status: .fail, errorMessage: error.localizedDescription) + Knock.shared.log(type: .error, category: .user, message: "updateUser", status: .fail, errorMessage: error.localizedDescription) throw error } } @@ -66,3 +66,4 @@ public extension Knock { } } } + diff --git a/Sources/Modules/Users/UserService.swift b/Sources/Modules/Users/UserService.swift index df4ead9..e02e7c9 100644 --- a/Sources/Modules/Users/UserService.swift +++ b/Sources/Modules/Users/UserService.swift @@ -7,13 +7,60 @@ import Foundation -internal class UserService: KnockAPIService { +internal protocol UserServiceProtocol { + func getUser(userId: String) async throws -> Knock.User + func updateUser(user: Knock.User) async throws -> Knock.User +} + +internal struct UserService: KnockAPIService, UserServiceProtocol { - internal func getUser() async throws -> Knock.User { - try await get(path: "/users/\(getSafeUserId())", queryItems: nil) + internal func getUser(userId: String) async throws -> Knock.User { + try await get(path: "/users/\(userId)", queryItems: nil) } internal func updateUser(user: Knock.User) async throws -> Knock.User { - try await put(path: "/users/\(getSafeUserId())", body: user) + try await put(path: "/users/\(user.id)", body: user) } } + +internal struct MockUserService: KnockAPIService, UserServiceProtocol { + + internal func getUser(userId: String) async throws -> Knock.User { + try await get(path: "/users/\(userId)", queryItems: nil) + } + + internal func updateUser(user: Knock.User) async throws -> Knock.User { + try await put(path: "/users/\(user.id)", body: user) + } +} + +//class MockAPIService2: KnockAPIService { +// private var response: Knock.User? +// private var error: Error? +// +// func setResponse(response: T, error: Error?) { +// +// } +// +// func get(path: String, queryItems: [URLQueryItem]?) async throws -> T { +// if let error = error { +// throw error +// } +// if let response = getUserResponse as? T { +// return response +// } +// throw NSError(domain: "", code: 0, userInfo: nil) // Generic error +// } +// +// func put(path: String, body: Encodable?) async throws -> T { +// if let error = error { +// throw error +// } +// if let response = updateUserResponse as? T { +// return response +// } +// throw NSError(domain: "", code: 0, userInfo: nil) // Generic error +// } +// +// // Implement other methods (post, delete) as needed +//} diff --git a/Tests/KnockTests/AuthenticationTests.swift b/Tests/KnockTests/AuthenticationTests.swift new file mode 100644 index 0000000..c391fe6 --- /dev/null +++ b/Tests/KnockTests/AuthenticationTests.swift @@ -0,0 +1,36 @@ +// +// AuthenticationTests.swift +// +// +// Created by Matt Gardner on 2/2/24. +// + +import XCTest +@testable import Knock + +final class AuthenticationTests: XCTestCase { + + override func setUpWithError() throws { + try? Knock.shared.setup(publishableKey: "pk_123", pushChannelId: "test") + } + + override func tearDownWithError() throws { + Task { + try? await Knock.shared.resetInstance() + } + } + + func testSignIn() throws { + let userName = "testUserName" + let userToken = "testUserToken" + Task { + try! await Knock.shared.signIn(userId: userName, userToken: userToken) + } + XCTAssertTrue(Knock.shared.environment.userId == userName && Knock.shared.environment.userToken == userToken) + } + + func testSignOut() throws { +// XCTAssertTrue(Knock.shared.environment.userId == userName && Knock.shared.environment.userToken == userToken) +// XCTAssertThrowsError(try Knock.shared.setup(publishableKey: "sk_123", pushChannelId: nil)) + } +} diff --git a/Tests/KnockTests/File.swift b/Tests/KnockTests/File.swift new file mode 100644 index 0000000..f68b904 --- /dev/null +++ b/Tests/KnockTests/File.swift @@ -0,0 +1,71 @@ +// +// UserTests.swift +// +// +// Created by Matt Gardner on 1/31/24. +// + +import XCTest +@testable import Knock + +final class UserTests: XCTestCase { + + override func setUpWithError() throws { + try? Knock.shared.setup(publishableKey: "pk_123", pushChannelId: "test") + } + + override func tearDownWithError() throws { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + func testPublishableKeyError() throws { + XCTAssertThrowsError(try Knock.shared.setup(publishableKey: "sk_123", pushChannelId: nil)) + } + + func testUserIdNilError() async throws { + do { + _ = try await Knock.shared.getUser() + XCTFail("Expected getUser() to throw, but it did not.") + } catch {} + } + + func testUserDecoding() throws { + let decoder = JSONDecoder() + + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSZZZZZ" + formatter.calendar = Calendar(identifier: .iso8601) + formatter.timeZone = TimeZone(secondsFromGMT: 0) + formatter.locale = Locale(identifier: "en_US_POSIX") + + decoder.dateDecodingStrategy = .formatted(formatter) + + let jsonString = """ + { + "id": "user-1", + "custom1": 1, + "extra2": { + "a": 2, + "b": 3, + "c": { + "a1": true + } + } + } + """ + + let jsonData = jsonString.data(using: .utf8)! + + let user = try decoder.decode(Knock.User.self, from: jsonData) + XCTAssertNotNil(user, "decodes user is nil") + XCTAssertNotNil(user.properties?["custom1"]) + XCTAssertEqual(user.properties!["custom1"], 1) + + let encoder = JSONEncoder() + let reencodedJSON = try encoder.encode(user) + let reencodedString = String(data: reencodedJSON, encoding: .utf8)! + + XCTAssertTrue(reencodedString.contains("extra2")) + XCTAssertTrue(reencodedString.contains("a1")) + } +} diff --git a/Tests/KnockTests/KnockTests.swift b/Tests/KnockTests/KnockTests.swift index d76a0e0..99027e7 100644 --- a/Tests/KnockTests/KnockTests.swift +++ b/Tests/KnockTests/KnockTests.swift @@ -12,7 +12,7 @@ final class KnockTests: XCTestCase { var knock: Knock! override func setUpWithError() throws { - knock = try! Knock(publishableKey: "pk_test") + try? Knock.shared.setup(publishableKey: "pk_123", pushChannelId: "test") } override func tearDownWithError() throws { @@ -20,7 +20,7 @@ final class KnockTests: XCTestCase { } func testPublishableKeyError() throws { - XCTAssertThrowsError(try Knock(publishableKey: "sk_123")) + XCTAssertThrowsError(try Knock.shared.setup(publishableKey: "sk_123", pushChannelId: nil)) } func testUserIdNilError() async throws { @@ -70,3 +70,13 @@ final class KnockTests: XCTestCase { XCTAssertTrue(reencodedString.contains("a1")) } } + +// signing in before token has registered +// making a network request before Knock has been setup +// making a network request before Knock has received userid +// registering for push without channel id +// signIn, make sure we have everything +// signOut, make sure everything is cleared +// deadlock + + From f30036873035b8608e0bacd81c63f6d03348202b Mon Sep 17 00:00:00 2001 From: Matt Gardner Date: Mon, 5 Feb 2024 16:13:38 -0700 Subject: [PATCH 21/22] refactored logging --- .../Authentication/AuthenticationModule.swift | 27 +++++++++---------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/Sources/Modules/Authentication/AuthenticationModule.swift b/Sources/Modules/Authentication/AuthenticationModule.swift index 417b0ff..58680c9 100644 --- a/Sources/Modules/Authentication/AuthenticationModule.swift +++ b/Sources/Modules/Authentication/AuthenticationModule.swift @@ -9,12 +9,15 @@ import Foundation internal class AuthenticationModule { - func signIn(userId: String, userToken: String?) async throws { + func signIn(userId: String, userToken: String?) async { Knock.shared.environment.setUserInfo(userId: userId, userToken: userToken) if let token = Knock.shared.environment.userDevicePushToken, let channelId = Knock.shared.environment.pushChannelId { - let _ = try await Knock.shared.channelModule.registerTokenForAPNS(channelId: channelId, token: token) - return + do { + try await Knock.shared.channelModule.registerTokenForAPNS(channelId: channelId, token: token) + } catch { + Knock.shared.logger.log(type: .warning, category: .user, message: "signIn", description: "Successfully set user, however, unable to registerTokenForAPNS as this time.") + } } return @@ -22,11 +25,11 @@ internal class AuthenticationModule { func signOut() async throws { guard let channelId = Knock.shared.environment.pushChannelId, let token = Knock.shared.environment.userDevicePushToken else { - Knock.shared.resetInstance() + Knock.shared.environment.setUserInfo(userId: nil, userToken: nil) return } let _ = try await Knock.shared.channelModule.unregisterTokenForAPNS(channelId: channelId, token: token) - Knock.shared.resetInstance() + Knock.shared.environment.setUserInfo(userId: nil, userToken: nil) return } } @@ -46,18 +49,14 @@ public extension Knock { Will also registerAPNS device token if set previously. You should consider using this in areas where you update your local user's state */ - func signIn(userId: String, userToken: String?) async throws { - try await authenticationModule.signIn(userId: userId, userToken: userToken) + func signIn(userId: String, userToken: String?) async { + await authenticationModule.signIn(userId: userId, userToken: userToken) } - func signIn(userId: String, userToken: String?, completionHandler: @escaping ((Result) -> Void)) { + func signIn(userId: String, userToken: String?, completionHandler: @escaping (() -> Void)) { Task { - do { - try await signIn(userId: userId, userToken: userToken) - completionHandler(.success(())) - } catch { - completionHandler(.failure(error)) - } + await signIn(userId: userId, userToken: userToken) + completionHandler() } } From 44d06e2fca9c4db9aca5ce3245b0e9ce7be3646d Mon Sep 17 00:00:00 2001 From: Matt Gardner Date: Mon, 5 Feb 2024 17:15:09 -0700 Subject: [PATCH 22/22] Adding more tests --- Sources/Helpers/Utilities.swift | 8 -- Sources/Knock.swift | 9 +- Sources/KnockEnvironment.swift | 13 +-- Sources/KnockErrors.swift | 13 +-- .../Authentication/AuthenticationModule.swift | 11 ++- Sources/Modules/Channels/ChannelModule.swift | 11 +-- Sources/Modules/Users/UserService.swift | 42 --------- Tests/KnockTests/AuthenticationTests.swift | 30 ++++--- Tests/KnockTests/ChannelTests.swift | 21 +++++ Tests/KnockTests/KnockTests.swift | 86 +++++++------------ .../{File.swift => UserTests.swift} | 11 +-- 11 files changed, 97 insertions(+), 158 deletions(-) create mode 100644 Tests/KnockTests/ChannelTests.swift rename Tests/KnockTests/{File.swift => UserTests.swift} (83%) diff --git a/Sources/Helpers/Utilities.swift b/Sources/Helpers/Utilities.swift index c40fc0d..e8599a5 100644 --- a/Sources/Helpers/Utilities.swift +++ b/Sources/Helpers/Utilities.swift @@ -33,14 +33,6 @@ internal extension Knock { } return tokenParts.joined() } - - func performActionWithUserId(_ action: @escaping (String, @escaping (Result) -> Void) -> Void, completionHandler: @escaping (Result) -> Void) { - guard let userId = self.userId else { - completionHandler(.failure(KnockError.userIdError)) - return - } - action(userId, completionHandler) - } } struct DynamicCodingKey: CodingKey { diff --git a/Sources/Knock.swift b/Sources/Knock.swift index 9cdccf5..6ebbbf1 100644 --- a/Sources/Knock.swift +++ b/Sources/Knock.swift @@ -12,7 +12,7 @@ import OSLog public class Knock { internal static let clientVersion = "1.0.0" - public static let shared: Knock = Knock() + public static var shared: Knock = Knock() public var feedManager: FeedManager? @@ -37,10 +37,9 @@ public class Knock { environment.setBaseUrl(baseUrl: options?.hostname) environment.pushChannelId = pushChannelId } - - public func resetInstance() async throws { - try await self.environment.resetInstance() - self.feedManager = nil + + public func resetInstanceCompletely() { + Knock.shared = Knock() } } diff --git a/Sources/KnockEnvironment.swift b/Sources/KnockEnvironment.swift index 5ead986..dbdd0bc 100644 --- a/Sources/KnockEnvironment.swift +++ b/Sources/KnockEnvironment.swift @@ -18,13 +18,6 @@ internal class KnockEnvironment { private(set) var publishableKey: String? private(set) var baseUrl: String = defaultBaseUrl - internal func resetInstance() async throws { - try await Knock.shared.authenticationModule.signOut() - setBaseUrl(baseUrl: nil) - publishableKey = nil - pushChannelId = nil - } - var userDevicePushToken: String? { get { defaults.string(forKey: userDevicePushTokenKey) @@ -45,7 +38,7 @@ internal class KnockEnvironment { func setPublishableKey(key: String) throws { guard key.hasPrefix("sk_") == false else { - let error = Knock.KnockError.publishableKeyError("You are using your secret API key on the client. Please use the public key.") + let error = Knock.KnockError.wrongKeyError Knock.shared.log(type: .error, category: .general, message: "setPublishableKey", status: .fail, errorMessage: error.localizedDescription) throw error } @@ -63,14 +56,14 @@ internal class KnockEnvironment { func getSafeUserId() throws -> String { guard let id = userId else { - throw Knock.KnockError.userIdError + throw Knock.KnockError.userIdNotSetError } return id } func getSafePublishableKey() throws -> String { guard let id = publishableKey else { - throw Knock.KnockError.publishableKeyError("You are trying to perform an action that requires you to first run Knock.shared.setup()") + throw Knock.KnockError.knockNotSetup } return id } diff --git a/Sources/KnockErrors.swift b/Sources/KnockErrors.swift index 6c1c203..cb06e1b 100644 --- a/Sources/KnockErrors.swift +++ b/Sources/KnockErrors.swift @@ -10,8 +10,9 @@ import Foundation public extension Knock { enum KnockError: Error, Equatable { case runtimeError(String) - case userIdError - case publishableKeyError(String) + case userIdNotSetError + case knockNotSetup + case wrongKeyError } struct NetworkError: NetworkErrorProtocol { @@ -35,10 +36,12 @@ extension Knock.KnockError: LocalizedError { switch self { case .runtimeError(let message): return message - case .userIdError: + case .userIdNotSetError: return "UserId not found. Please authenticate your userId with Knock.signIn()." - case .publishableKeyError(let message): - return message + case .knockNotSetup: + return "Knock instance still needs to be setup. Please setup with Knock.shared.setup()." + case .wrongKeyError: + return "You are using your secret API key on the client. Please use the public key." } } } diff --git a/Sources/Modules/Authentication/AuthenticationModule.swift b/Sources/Modules/Authentication/AuthenticationModule.swift index 58680c9..3f922a6 100644 --- a/Sources/Modules/Authentication/AuthenticationModule.swift +++ b/Sources/Modules/Authentication/AuthenticationModule.swift @@ -14,7 +14,7 @@ internal class AuthenticationModule { if let token = Knock.shared.environment.userDevicePushToken, let channelId = Knock.shared.environment.pushChannelId { do { - try await Knock.shared.channelModule.registerTokenForAPNS(channelId: channelId, token: token) + let _ = try await Knock.shared.channelModule.registerTokenForAPNS(channelId: channelId, token: token) } catch { Knock.shared.logger.log(type: .warning, category: .user, message: "signIn", description: "Successfully set user, however, unable to registerTokenForAPNS as this time.") } @@ -25,13 +25,18 @@ internal class AuthenticationModule { func signOut() async throws { guard let channelId = Knock.shared.environment.pushChannelId, let token = Knock.shared.environment.userDevicePushToken else { - Knock.shared.environment.setUserInfo(userId: nil, userToken: nil) + clearDataForSignOut() return } + let _ = try await Knock.shared.channelModule.unregisterTokenForAPNS(channelId: channelId, token: token) - Knock.shared.environment.setUserInfo(userId: nil, userToken: nil) + clearDataForSignOut() return } + + func clearDataForSignOut() { + Knock.shared.environment.setUserInfo(userId: nil, userToken: nil) + } } public extension Knock { diff --git a/Sources/Modules/Channels/ChannelModule.swift b/Sources/Modules/Channels/ChannelModule.swift index a86b58e..917a026 100644 --- a/Sources/Modules/Channels/ChannelModule.swift +++ b/Sources/Modules/Channels/ChannelModule.swift @@ -19,6 +19,7 @@ internal class ChannelModule { func getUserChannelData(channelId: String) async throws -> Knock.ChannelData { do { let data = try await channelService.getUserChannelData(userId: Knock.shared.environment.getSafeUserId(), channelId: channelId) + Knock.shared.log(type: .debug, category: .channel, message: "getUserChannelData", status: .success) return data } catch let error { Knock.shared.log(type: .warning, category: .channel, message: "getUserChannelData", status: .fail, errorMessage: error.localizedDescription) @@ -68,7 +69,7 @@ internal class ChannelModule { // Register the new token return try await registerOrUpdateToken(token: token, channelId: channelId, existingTokens: tokens) } - } catch let userIdError as Knock.KnockError where userIdError == Knock.KnockError.userIdError { + } catch let userIdError as Knock.KnockError where userIdError == Knock.KnockError.userIdNotSetError { Knock.shared.log(type: .warning, category: .pushNotification, message: "ChannelId and deviceToken were saved. However, we cannot register for APNS until you have have called Knock.signIn().") throw userIdError } catch { @@ -116,13 +117,7 @@ internal class ChannelModule { public extension Knock { func getUserChannelData(channelId: String) async throws -> ChannelData { - do { - try await self.channelModule.getUserChannelData(channelId: channelId) - Knock.shared.log(type: .debug, category: .channel, message: "getUserChannelData", status: .success) - } catch let error { - Knock.shared.log(type: .warning, category: .channel, message: "getUserChannelData", status: .fail, errorMessage: error.localizedDescription) - throw error - } + try await self.channelModule.getUserChannelData(channelId: channelId) } func getUserChannelData(channelId: String, completionHandler: @escaping ((Result) -> Void)) { diff --git a/Sources/Modules/Users/UserService.swift b/Sources/Modules/Users/UserService.swift index e02e7c9..57cbd86 100644 --- a/Sources/Modules/Users/UserService.swift +++ b/Sources/Modules/Users/UserService.swift @@ -22,45 +22,3 @@ internal struct UserService: KnockAPIService, UserServiceProtocol { try await put(path: "/users/\(user.id)", body: user) } } - -internal struct MockUserService: KnockAPIService, UserServiceProtocol { - - internal func getUser(userId: String) async throws -> Knock.User { - try await get(path: "/users/\(userId)", queryItems: nil) - } - - internal func updateUser(user: Knock.User) async throws -> Knock.User { - try await put(path: "/users/\(user.id)", body: user) - } -} - -//class MockAPIService2: KnockAPIService { -// private var response: Knock.User? -// private var error: Error? -// -// func setResponse(response: T, error: Error?) { -// -// } -// -// func get(path: String, queryItems: [URLQueryItem]?) async throws -> T { -// if let error = error { -// throw error -// } -// if let response = getUserResponse as? T { -// return response -// } -// throw NSError(domain: "", code: 0, userInfo: nil) // Generic error -// } -// -// func put(path: String, body: Encodable?) async throws -> T { -// if let error = error { -// throw error -// } -// if let response = updateUserResponse as? T { -// return response -// } -// throw NSError(domain: "", code: 0, userInfo: nil) // Generic error -// } -// -// // Implement other methods (post, delete) as needed -//} diff --git a/Tests/KnockTests/AuthenticationTests.swift b/Tests/KnockTests/AuthenticationTests.swift index c391fe6..4552e24 100644 --- a/Tests/KnockTests/AuthenticationTests.swift +++ b/Tests/KnockTests/AuthenticationTests.swift @@ -15,22 +15,30 @@ final class AuthenticationTests: XCTestCase { } override func tearDownWithError() throws { - Task { - try? await Knock.shared.resetInstance() - } + Knock.shared = Knock() } + - func testSignIn() throws { + func testSignIn() async throws { let userName = "testUserName" let userToken = "testUserToken" - Task { - try! await Knock.shared.signIn(userId: userName, userToken: userToken) - } - XCTAssertTrue(Knock.shared.environment.userId == userName && Knock.shared.environment.userToken == userToken) + await Knock.shared.signIn(userId: userName, userToken: userToken) + + XCTAssertEqual(userName, Knock.shared.environment.userId) + XCTAssertEqual(userToken, Knock.shared.environment.userToken) } - func testSignOut() throws { -// XCTAssertTrue(Knock.shared.environment.userId == userName && Knock.shared.environment.userToken == userToken) -// XCTAssertThrowsError(try Knock.shared.setup(publishableKey: "sk_123", pushChannelId: nil)) + func testSignOut() async throws { + await Knock.shared.signIn(userId: "testUserName", userToken: "testUserToken") + Knock.shared.environment.userDevicePushToken = "test" + Knock.shared.environment.userDevicePushToken = "test" + Knock.shared.environment.userDevicePushToken = "test" + + Knock.shared.authenticationModule.clearDataForSignOut() + + XCTAssertEqual(Knock.shared.environment.userId, nil) + XCTAssertEqual(Knock.shared.environment.userToken, nil) + XCTAssertEqual(Knock.shared.environment.publishableKey, "pk_123") + XCTAssertEqual(Knock.shared.environment.userDevicePushToken, "test") } } diff --git a/Tests/KnockTests/ChannelTests.swift b/Tests/KnockTests/ChannelTests.swift new file mode 100644 index 0000000..6dc527b --- /dev/null +++ b/Tests/KnockTests/ChannelTests.swift @@ -0,0 +1,21 @@ +// +// ChannelTests.swift +// +// +// Created by Matt Gardner on 2/5/24. +// + +import XCTest +@testable import Knock + +final class ChannelTests: XCTestCase { + + override func setUpWithError() throws { + try? Knock.shared.setup(publishableKey: "pk_123", pushChannelId: "test") + } + + override func tearDownWithError() throws { + Knock.shared = Knock() + } + +} diff --git a/Tests/KnockTests/KnockTests.swift b/Tests/KnockTests/KnockTests.swift index 99027e7..b6bc977 100644 --- a/Tests/KnockTests/KnockTests.swift +++ b/Tests/KnockTests/KnockTests.swift @@ -9,74 +9,48 @@ import XCTest @testable import Knock final class KnockTests: XCTestCase { - var knock: Knock! - override func setUpWithError() throws { try? Knock.shared.setup(publishableKey: "pk_123", pushChannelId: "test") } override func tearDownWithError() throws { - // Put teardown code here. This method is called after the invocation of each test method in the class. + Knock.shared = Knock() } - + func testPublishableKeyError() throws { - XCTAssertThrowsError(try Knock.shared.setup(publishableKey: "sk_123", pushChannelId: nil)) + do { + let _ = try Knock.shared.setup(publishableKey: "sk_123", pushChannelId: nil) + XCTFail("Expected function to throw an error, but it did not.") + } catch let error as Knock.KnockError { + XCTAssertEqual(error, Knock.KnockError.wrongKeyError, "The error should be wrongKeyError") + } catch { + XCTFail("Expected KnockError, but received a different error.") + } } - func testUserIdNilError() async throws { + func testMakingNetworkRequestBeforeKnockSetUp() async { + try! tearDownWithError() + Knock.shared.environment.setUserInfo(userId: "test", userToken: nil) do { - _ = try await knock.getUser() - XCTFail("Expected getUser() to throw, but it did not.") - } catch {} + let _ = try await Knock.shared.getUser() + XCTFail("Expected function to throw an error, but it did not.") + } catch let error as Knock.KnockError { + XCTAssertEqual(error, Knock.KnockError.knockNotSetup, "The error should be knockNotSetup") + } catch { + XCTFail("Expected KnockError, but received a different error.") + } } - func testUserDecoding() throws { - let decoder = JSONDecoder() - - let formatter = DateFormatter() - formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSZZZZZ" - formatter.calendar = Calendar(identifier: .iso8601) - formatter.timeZone = TimeZone(secondsFromGMT: 0) - formatter.locale = Locale(identifier: "en_US_POSIX") - - decoder.dateDecodingStrategy = .formatted(formatter) - - let jsonString = """ - { - "id": "user-1", - "custom1": 1, - "extra2": { - "a": 2, - "b": 3, - "c": { - "a1": true - } - } + func testMakingNetworkRequestBeforeSignIn() async { + do { + let _ = try await Knock.shared.getUser() + XCTFail("Expected function to throw an error, but it did not.") + } catch let error as Knock.KnockError { + XCTAssertEqual(error, Knock.KnockError.userIdNotSetError, "The error should be userIdNotSetError") + } catch { + XCTFail("Expected KnockError, but received a different error.") } - """ - - let jsonData = jsonString.data(using: .utf8)! - - let user = try decoder.decode(Knock.User.self, from: jsonData) - XCTAssertNotNil(user, "decodes user is nil") - XCTAssertNotNil(user.properties?["custom1"]) - XCTAssertEqual(user.properties!["custom1"], 1) - - let encoder = JSONEncoder() - let reencodedJSON = try encoder.encode(user) - let reencodedString = String(data: reencodedJSON, encoding: .utf8)! - - XCTAssertTrue(reencodedString.contains("extra2")) - XCTAssertTrue(reencodedString.contains("a1")) } + + } - -// signing in before token has registered -// making a network request before Knock has been setup -// making a network request before Knock has received userid -// registering for push without channel id -// signIn, make sure we have everything -// signOut, make sure everything is cleared -// deadlock - - diff --git a/Tests/KnockTests/File.swift b/Tests/KnockTests/UserTests.swift similarity index 83% rename from Tests/KnockTests/File.swift rename to Tests/KnockTests/UserTests.swift index f68b904..8c55a5c 100644 --- a/Tests/KnockTests/File.swift +++ b/Tests/KnockTests/UserTests.swift @@ -17,17 +17,8 @@ final class UserTests: XCTestCase { override func tearDownWithError() throws { // Put teardown code here. This method is called after the invocation of each test method in the class. } - - func testPublishableKeyError() throws { - XCTAssertThrowsError(try Knock.shared.setup(publishableKey: "sk_123", pushChannelId: nil)) - } - func testUserIdNilError() async throws { - do { - _ = try await Knock.shared.getUser() - XCTFail("Expected getUser() to throw, but it did not.") - } catch {} - } + func testUserDecoding() throws { let decoder = JSONDecoder()