From 9396a8d097f96565c844e6a0f8c9580388fc9aee Mon Sep 17 00:00:00 2001 From: Matt Gardner Date: Tue, 30 Jan 2024 13:23:36 -0700 Subject: [PATCH 01/10] Initialization Strategy and Unregistering from Push (#2) * Starting on initial refactor * initial refactor * Implemented shared Knock instance * added device unregistration functionality * Added more safety around the userId checks * Cleanup * Make KnockError a LocalizedError * Updated KnockOptions init * updates --- Sources/Helpers/Utilities.swift | 15 ++ Sources/Knock.swift | 68 ++++--- Sources/KnockAPI.swift | 34 ++-- Sources/KnockAppDelegate.swift | 90 ++++++++++ Sources/Modules/Channels/ChannelService.swift | 115 ++++++++---- Sources/Modules/Feed/FeedManager.swift | 166 +++--------------- .../Feed/Models/FeedClientOptions.swift | 104 +++++++++++ .../Modules/Feed/Models/FeedItemScope.swift | 40 +++++ .../Preferences/PreferenceService.swift | 14 +- Sources/Modules/Users/UserService.swift | 74 +++++++- 10 files changed, 498 insertions(+), 222 deletions(-) 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/Helpers/Utilities.swift b/Sources/Helpers/Utilities.swift index 16bca0c..c40fc0d 100644 --- a/Sources/Helpers/Utilities.swift +++ b/Sources/Helpers/Utilities.swift @@ -26,6 +26,21 @@ 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() + } + + 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 fb9921d..f28a599 100644 --- a/Sources/Knock.swift +++ b/Sources/Knock.swift @@ -7,37 +7,63 @@ import SwiftUI +// Knock client SDK. public class Knock { - public let publishableKey: String - public let userId: String - public let userToken: String? + internal static let clientVersion = "1.0.0" + internal static let loggingSubsytem = "knock-swift" - internal let api: KnockAPI - - public var feedManager: FeedManager? - - public enum KnockError: Error { - case runtimeError(String) - } - - // MARK: Constructor + internal var api: KnockAPI + 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 - - hostname: [optional] custom hostname of the API, including schema (https://) + - options: [optional] Options for customizing the Knock instance. */ - 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 + public init(publishableKey: String, options: KnockOptions? = nil) { + self.api = KnockAPI(publishableKey: publishableKey, hostname: options?.host) + } + + internal func resetInstance() { + self.userId = nil + self.feedManager = nil + self.userDeviceToken = nil + self.pushChannelId = nil + self.api.userToken = nil + } +} + +public extension Knock { + // Configuration options for the Knock client SDK. + struct KnockOptions { + var host: String? - self.api = KnockAPI(publishableKey: publishableKey, userToken: userToken, hostname: hostname) + public init(host: String? = nil) { + self.host = host + } + } + + enum KnockError: Error { + case runtimeError(String) + case userIdError + } +} + +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()." + } } } diff --git a/Sources/KnockAPI.swift b/Sources/KnockAPI.swift index d943749..b78fdec 100644 --- a/Sources/KnockAPI.swift +++ b/Sources/KnockAPI.swift @@ -8,51 +8,48 @@ import Foundation class KnockAPI { - private let publishableKey: String - private let userToken: String? - public var hostname = "https://api.knock.app" + internal let publishableKey: String + internal private(set) var host = "https://api.knock.app" + public internal(set) var userToken: String? + private var apiBasePath: String { - "\(hostname)/v1" + "\(host)/v1" } - - static let clientVersion = "0.2.0" - - init(publishableKey: String, userToken: String? = nil, hostname: String? = nil) { - self.publishableKey = publishableKey - self.userToken = userToken - + + internal init(publishableKey: String, hostname: String? = nil) { if let customHostname = hostname { - self.hostname = customHostname + self.host = customHostname } + self.publishableKey = publishableKey } // 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() @@ -123,6 +120,7 @@ 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) { + let sessionConfig = URLSessionConfiguration.default let session = URLSession(configuration: sessionConfig, delegate: nil, delegateQueue: nil) guard var URL = URL(string: "\(apiBasePath)\(path)") else {return} @@ -144,7 +142,7 @@ class KnockAPI { // Headers - request.addValue("knock-swift@\(KnockAPI.clientVersion)", forHTTPHeaderField: "User-Agent") + request.addValue("knock-swift@\(Knock.clientVersion)", forHTTPHeaderField: "User-Agent") request.addValue("Bearer \(publishableKey)", forHTTPHeaderField: "Authorization") if let userToken = userToken { diff --git a/Sources/KnockAppDelegate.swift b/Sources/KnockAppDelegate.swift new file mode 100644 index 0000000..6b68def --- /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/Channels/ChannelService.swift b/Sources/Modules/Channels/ChannelService.swift index ca52093..efe98c1 100644 --- a/Sources/Modules/Channels/ChannelService.swift +++ b/Sources/Modules/Channels/ChannelService.swift @@ -6,11 +6,17 @@ // 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/\(userId)/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) } /** @@ -21,12 +27,16 @@ 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/\(userId)/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 + /** 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. @@ -40,10 +50,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) } @@ -60,49 +67,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 - print("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 - print("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 - print("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 - print("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 - print("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 1f25397..5e94b45 100644 --- a/Sources/Modules/Feed/FeedManager.swift +++ b/Sources/Modules/Feed/FeedManager.swift @@ -7,151 +7,38 @@ import Foundation 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 + } class FeedManager { private let api: KnockAPI + private let userId: String 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 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)) { + 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 = client.api.hostname.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": client.publishableKey, "user_token": client.userToken ?? ""]) - self.userId = client.userId + 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):\(client.userId)" - self.api = client.api + self.feedTopic = "feeds:\(feedId):\(userId)" self.defaultFeedOptions = options } @@ -164,24 +51,25 @@ 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) } + // 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) @@ -195,10 +83,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() @@ -211,12 +99,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() 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 + } +} diff --git a/Sources/Modules/Preferences/PreferenceService.swift b/Sources/Modules/Preferences/PreferenceService.swift index f0d3518..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/\(userId)/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/\(userId)/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/\(userId)/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 866b2f1..6889217 100644 --- a/Sources/Modules/Users/UserService.swift +++ b/Sources/Modules/Users/UserService.swift @@ -6,13 +6,83 @@ // import Foundation +import OSLog public extension Knock { + + private var logger: Logger { + Logger(subsystem: Knock.loggingSubsytem, category: "UserService") + } + + /** + 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)) { - self.api.decodeFromGet(User.self, path: "/users/\(userId)", 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/\(userId)", body: user, then: completionHandler) + performActionWithUserId( { userId, completion in + self.api.decodeFromPut(User.self, path: "/users/\(userId)", body: user, then: completion) + }, completionHandler: completionHandler) } } From c35fbd7c1bd643583cc808c1e10d31352d53b365 Mon Sep 17 00:00:00 2001 From: Matt Gardner Date: Mon, 5 Feb 2024 17:20:53 -0700 Subject: [PATCH 02/10] Matgar26 kno 5091 modules (#3) * Starting on initial refactor * initial refactor * Implemented shared Knock instance * added device unregistration functionality * Added more safety around the userId checks * Cleanup * Make KnockError a LocalizedError * Updated KnockOptions init * updates * Introduce Modules and Services for independent responsibilities * Authentication * fixes * set version on api base path * Fixes * fix * implementation fix * Updated logging * Working on tests * created shared Knock instance * refactored logging * refactored logging * Adding more tests --------- Co-authored-by: Matt Gardner --- .../xcshareddata/xcschemes/Knock.xcscheme | 12 + Package.swift | 7 +- Sources/Helpers/Utilities.swift | 8 - Sources/Knock.swift | 70 ++--- Sources/KnockAPI.swift | 205 ------------- Sources/KnockAPIService.swift | 114 ++++++++ Sources/KnockAppDelegate.swift | 95 +++---- Sources/KnockEnvironment.swift | 70 +++++ Sources/KnockErrors.swift | 52 ++++ Sources/KnockLogger.swift | 91 ++++++ .../Authentication/AuthenticationModule.swift | 87 ++++++ Sources/Modules/Channels/ChannelModule.swift | 269 ++++++++++++++++++ Sources/Modules/Channels/ChannelService.swift | 144 +--------- Sources/Modules/Feed/FeedManager.swift | 217 +++++--------- Sources/Modules/Feed/FeedModule.swift | 193 +++++++++++++ Sources/Modules/Feed/FeedService.swift | 18 ++ Sources/Modules/Feed/Models/Block.swift | 2 +- .../Modules/Feed/Models/BulkOperation.swift | 2 +- Sources/Modules/Feed/Models/Feed.swift | 2 +- .../Feed/Models/FeedClientOptions.swift | 22 +- .../Modules/Feed/Models/FeedItemScope.swift | 2 +- Sources/Modules/Messages/MessageModule.swift | 182 ++++++++++++ Sources/Modules/Messages/MessageService.swift | 56 +--- .../Preferences/PreferenceModule.swift | 93 ++++++ .../Preferences/PreferenceService.swift | 25 +- Sources/Modules/Users/UserModule.swift | 69 +++++ Sources/Modules/Users/UserService.swift | 82 +----- Tests/KnockTests/AuthenticationTests.swift | 44 +++ Tests/KnockTests/ChannelTests.swift | 21 ++ Tests/KnockTests/KnockTests.swift | 56 ++++ .../KnockTests/UserTests.swift | 34 +-- 31 files changed, 1583 insertions(+), 761 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/KnockLogger.swift create mode 100644 Sources/Modules/Authentication/AuthenticationModule.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 create mode 100644 Tests/KnockTests/AuthenticationTests.swift create mode 100644 Tests/KnockTests/ChannelTests.swift create mode 100644 Tests/KnockTests/KnockTests.swift rename KnockTests/KnockTests.swift => Tests/KnockTests/UserTests.swift (55%) 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"> + + + + + + (_ 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 f28a599..6ebbbf1 100644 --- a/Sources/Knock.swift +++ b/Sources/Knock.swift @@ -6,64 +6,66 @@ // import SwiftUI +import OSLog // Knock client SDK. public class Knock { internal static let clientVersion = "1.0.0" - internal static let loggingSubsytem = "knock-swift" - internal var api: KnockAPI + public static var 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() + internal lazy var channelModule = ChannelModule() + internal lazy var logger = KnockLogger() - 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 func setup(publishableKey: String, pushChannelId: String?, options: Knock.KnockStartupOptions? = nil) throws { + logger.loggingDebugOptions = options?.debuggingType ?? .errorsOnly + try environment.setPublishableKey(key: publishableKey) + environment.setBaseUrl(baseUrl: options?.hostname) + environment.pushChannelId = pushChannelId } - internal func resetInstance() { - self.userId = nil - self.feedManager = nil - self.userDeviceToken = nil - self.pushChannelId = nil - self.api.userToken = nil + public func resetInstanceCompletely() { + Knock.shared = Knock() } } public extension Knock { - // Configuration options for the Knock client SDK. - struct KnockOptions { - var host: String? - - public init(host: String? = nil) { - self.host = host + struct KnockStartupOptions { + public init(hostname: String? = nil, debuggingType: DebugOptions = .errorsOnly) { + self.hostname = hostname + self.debuggingType = debuggingType } + var hostname: String? + var debuggingType: DebugOptions } - enum KnockError: Error { - case runtimeError(String) - case userIdError + enum DebugOptions { + case errorsOnly + case verbose + case none } } -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()." - } +public extension Knock { + var userId: String? { + get { return environment.userId } + } + + var apnsDeviceToken: String? { + get { return environment.userId } } } 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..6ba2f8f --- /dev/null +++ b/Sources/KnockAPIService.swift @@ -0,0 +1,114 @@ +// +// KnockAPIService.swift +// KnockSample +// +// Created by Matt on 01/29/2023. +// + +import Foundation +import OSLog + +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" + } + + 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) \(apiBaseUrl)\(path)" + + guard var URL = URL(string: "\(apiBaseUrl)\(path)") else { + let networkError = Knock.NetworkError(title: "Invalid URL", description: "The URL: \(apiBaseUrl)\(path) is invalid", code: 0) + Knock.shared.log(type: .warning, category: .networking, message: loggingMessageSummary, status: .fail, errorMessage: networkError.localizedDescription) + throw networkError + } + + 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 \(try Knock.shared.environment.getSafePublishableKey())", forHTTPHeaderField: "Authorization") + if let userToken = Knock.shared.environment.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 { + let networkError = Knock.NetworkError(title: "Status code error", description: String(data: responseData, encoding: .utf8) ?? "Unknown error", code: statusCode) + Knock.shared.log(type: .warning, category: .networking, message: loggingMessageSummary, status: .fail, errorMessage: networkError.localizedDescription) + throw networkError + } else { + Knock.shared.log(type: .debug, category: .networking, message: loggingMessageSummary, status: .success) + } + + return try decodeData(responseData) + } + 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) + + 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)") + Knock.shared.log(type: .error, category: .networking, message: "Error decoding data: \(dataString)", status: .fail, errorMessage: decodeError.localizedDescription) + throw decodeError + } else { + 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 6b68def..47a3df4 100644 --- a/Sources/KnockAppDelegate.swift +++ b/Sources/KnockAppDelegate.swift @@ -11,80 +11,79 @@ import OSLog @available(iOSApplicationExtension, unavailable) open class KnockAppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterDelegate { - - private let logger = Logger(subsystem: "knock-swift", category: "KnockAppDelegate") - + // 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) { - logger.debug("userNotificationCenter willPresent notification: \(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) { - logger.debug("didReceiveNotificationResponse: \(response)") - - let userInfo = response.notification.request.content.userInfo - - if response.actionIdentifier == UNNotificationDismissActionIdentifier { - pushNotificationDismissed(userInfo: userInfo) - } else { - pushNotificationTapped(userInfo: userInfo) + open func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { + Task { + let channelId = Knock.shared.environment.pushChannelId + + 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) { - logger.error("Failed to register for notifications: \(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) { - 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 + 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 new file mode 100644 index 0000000..dbdd0bc --- /dev/null +++ b/Sources/KnockEnvironment.swift @@ -0,0 +1,70 @@ +// +// KnockEnvironment.swift +// +// +// Created by Matt Gardner on 1/29/24. +// + +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? + private(set) var publishableKey: String? + private(set) var baseUrl: String = defaultBaseUrl + + 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.wrongKeyError + Knock.shared.log(type: .error, category: .general, message: "setPublishableKey", status: .fail, errorMessage: error.localizedDescription) + throw error + } + self.publishableKey = key + } + + func setUserInfo(userId: String?, userToken: String?) { + self.userId = userId + self.userToken = userToken + } + + func setBaseUrl(baseUrl: String?) { + self.baseUrl = "\(baseUrl ?? KnockEnvironment.defaultBaseUrl)" + } + + func getSafeUserId() throws -> String { + guard let id = userId else { + throw Knock.KnockError.userIdNotSetError + } + return id + } + + func getSafePublishableKey() throws -> String { + guard let id = publishableKey else { + throw Knock.KnockError.knockNotSetup + } + return id + } +} diff --git a/Sources/KnockErrors.swift b/Sources/KnockErrors.swift new file mode 100644 index 0000000..cb06e1b --- /dev/null +++ b/Sources/KnockErrors.swift @@ -0,0 +1,52 @@ +// +// KnockErrors.swift +// +// +// Created by Matt Gardner on 1/29/24. +// + +import Foundation + +public extension Knock { + enum KnockError: Error, Equatable { + case runtimeError(String) + case userIdNotSetError + case knockNotSetup + case wrongKeyError + } + + 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 .userIdNotSetError: + return "UserId not found. Please authenticate your userId with Knock.signIn()." + 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." + } + } +} + +protocol NetworkErrorProtocol: LocalizedError { + var title: String? { get } + var code: Int { get } +} diff --git a/Sources/KnockLogger.swift b/Sources/KnockLogger.swift new file mode 100644 index 0000000..5f5ea72 --- /dev/null +++ b/Sources/KnockLogger.swift @@ -0,0 +1,91 @@ +// +// 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 var loggingDebugOptions: Knock.DebugOptions = .errorsOnly + + 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 { + 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: KnockLogger.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 + 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/Authentication/AuthenticationModule.swift b/Sources/Modules/Authentication/AuthenticationModule.swift new file mode 100644 index 0000000..3f922a6 --- /dev/null +++ b/Sources/Modules/Authentication/AuthenticationModule.swift @@ -0,0 +1,87 @@ +// +// AuthenticationModule.swift +// +// +// Created by Matt Gardner on 1/30/24. +// + +import Foundation + +internal class AuthenticationModule { + + 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 { + do { + 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.") + } + } + + return + } + + func signOut() async throws { + guard let channelId = Knock.shared.environment.pushChannelId, let token = Knock.shared.environment.userDevicePushToken else { + clearDataForSignOut() + return + } + + let _ = try await Knock.shared.channelModule.unregisterTokenForAPNS(channelId: channelId, token: token) + clearDataForSignOut() + return + } + + func clearDataForSignOut() { + Knock.shared.environment.setUserInfo(userId: nil, userToken: nil) + } +} + +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 { + await authenticationModule.signIn(userId: userId, userToken: userToken) + } + + func signIn(userId: String, userToken: String?, completionHandler: @escaping (() -> Void)) { + Task { + await signIn(userId: userId, userToken: userToken) + completionHandler() + } + } + + /** + 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 new file mode 100644 index 0000000..917a026 --- /dev/null +++ b/Sources/Modules/Channels/ChannelModule.swift @@ -0,0 +1,269 @@ +// +// ChannelModule.swift +// +// +// Created by Matt Gardner on 1/26/24. +// + +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(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) + throw error + } + } + + func updateUserChannelData(channelId: String, data: AnyEncodable) async throws -> Knock.ChannelData { + do { + 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 { + Knock.shared.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 { + var tokens = existingTokens ?? [] + if !tokens.contains(token) { + tokens.append(token) + } + + let data: AnyEncodable = ["tokens": tokens] + let channelData = try await updateUserChannelData(channelId: channelId, data: data) + 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.pushChannelId = channelId + Knock.shared.environment.userDevicePushToken = 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 + return try await registerOrUpdateToken(token: token, channelId: channelId, existingTokens: nil) + } + + 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.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 { + // 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. + 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 + } + + if tokens.contains(token) { + let newTokensSet = Set(tokens).subtracting([token]) + let newTokens = Array(newTokensSet) + let data: AnyEncodable = [ + "tokens": newTokens + ] + let updateData = try await updateUserChannelData(channelId: channelId, data: data) + Knock.shared.log(type: .debug, category: .pushNotification, message: "unregisterTokenForAPNS", status: .success) + return updateData + } else { + 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 + 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. + Knock.shared.log(type: .error, category: .pushNotification, message: "unregisterTokenForAPNS", description: "Could not unregister user from channel \(channelId)", status: .fail, errorMessage: 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) + } + + 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 efe98c1..0eca6a0 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(userId: String, channelId: String) async throws -> Knock.ChannelData { + try await get(path: "/users/\(userId)/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(userId: String, channelId: String, data: AnyEncodable) async throws -> Knock.ChannelData { + let body = ["data": data] + 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 5e94b45..84a56ac 100644 --- a/Sources/Modules/Feed/FeedManager.swift +++ b/Sources/Modules/Feed/FeedManager.swift @@ -8,38 +8,41 @@ import Foundation import SwiftPhoenixClient import OSLog +import UIKit 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 - } - + 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 + private var foregroundObserver: NSObjectProtocol? + private var backgroundObserver: NSObjectProtocol? - 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) + 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) + } } /** @@ -49,69 +52,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 +70,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 +95,27 @@ 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 + } + + 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 new file mode 100644 index 0000000..d850403 --- /dev/null +++ b/Sources/Modules/Feed/FeedModule.swift @@ -0,0 +1,193 @@ +// +// 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 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 = 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 Knock.shared.environment.getSafeUserId() + } catch let error { + Knock.shared.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": try Knock.shared.environment.getSafePublishableKey(), "user_token": Knock.shared.environment.userToken ?? ""]) + self.feedId = feedId + self.feedTopic = "feeds:\(feedId):\(userId)" + self.feedOptions = options + Knock.shared.log(type: .debug, category: .feed, message: "FeedManager", status: .success) + } + + 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) + ] + + do { + 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 { + Knock.shared.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 { + // 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 Knock.shared.environment.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!] : "" + ] + do { + let op = try await feedService.makeBulkStatusUpdate(feedId: feedId, type: type, body: body) + Knock.shared.log(type: .debug, category: .feed, message: "makeBulkStatusUpdate", status: .success) + return op + } catch let error { + Knock.shared.log(type: .error, category: .feed, message: "makeBulkStatusUpdate", status: .fail, errorMessage: error.localizedDescription) + throw error + } + } + + func disconnectFromFeed() { + Knock.shared.log(type: .debug, category: .feed, message: "Disconnecting from feed") + + if let channel = self.feedChannel { + channel.leave() + self.socket.remove(channel) + } + + self.socket.disconnect() + } + + // 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 + completionHandler(message) + } + } + else { + 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 + Knock.shared.log(type: .debug, category: .feed, message: "connectToFeed", description: "Socket Opened") + } + + socket.delegateOnClose(to: self) { (self) in + 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 { + Knock.shared.log(type: .error, category: .feed, message: "connectToFeed", description: "Socket Errored \(statusCode)", status: .fail, errorMessage: error.localizedDescription) + self.socket.disconnect() + } else { + 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 + Knock.shared.log(type: .debug, category: .feed, message: "SwiftPhoenixClient", description: 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 + Knock.shared.log(type: .debug, category: .feed, message: "connectToFeed", description: "CHANNEL: \(channel.topic) joined") + } + .delegateReceive("error", to: self) { (self, message) in + Knock.shared.log(type: .error, category: .feed, message: "connectToFeed", status: .fail, errorMessage: "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..47e38e3 --- /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(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 { + try await post(path: "/channels/\(feedId)/messages/bulk/\(type.rawValue)", body: body) + } +} 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/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..7b19c88 --- /dev/null +++ b/Sources/Modules/Messages/MessageModule.swift @@ -0,0 +1,182 @@ +// +// 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 { + do { + let message = try await messageService.getMessage(messageId: messageId) + Knock.shared.log(type: .debug, category: .message, message: "getMessage", status: .success, additionalInfo: ["messageId": messageId]) + return message + } catch let error { + Knock.shared.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 { + do { + let message = try await messageService.updateMessageStatus(messageId: messageId, status: status) + Knock.shared.log(type: .debug, category: .message, message: "updateMessageStatus", status: .success, additionalInfo: ["messageId": messageId]) + return message + } catch let error { + Knock.shared.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 { + do { + let message = try await messageService.deleteMessageStatus(messageId: messageId, status: status) + Knock.shared.log(type: .debug, category: .message, message: "deleteMessageStatus", status: .success, additionalInfo: ["messageId": messageId]) + return message + } catch let error { + Knock.shared.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] { + do { + let messages = try await messageService.batchUpdateStatuses(messageIds: messageIds, status: status) + Knock.shared.log(type: .debug, category: .message, message: "batchUpdateStatuses", status: .success) + return messages + } catch let error { + Knock.shared.log(type: .error, category: .message, message: "batchUpdateStatuses", status: .fail, errorMessage: error.localizedDescription) + throw error + } + } +} + +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..9cc0c1e --- /dev/null +++ b/Sources/Modules/Preferences/PreferenceModule.swift @@ -0,0 +1,93 @@ +// +// 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] { + do { + 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 { + 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(userId: Knock.shared.environment.getSafeUserId(), preferenceId: preferenceId) + Knock.shared.log(type: .debug, category: .preferences, message: "getUserPreferences", status: .success) + return set + } catch let error { + 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(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 { + Knock.shared.log(type: .error, category: .preferences, message: "setUserPreferences", status: .fail, errorMessage: error.localizedDescription) + throw error + } + } +} + +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..c20d9cd 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(userId: String) async throws -> [Knock.PreferenceSet] { + try await get(path: "/users/\(userId)/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(userId: String, preferenceId: String) async throws -> Knock.PreferenceSet { + try await get(path: "/users/\(userId)/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(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 new file mode 100644 index 0000000..ff06b1a --- /dev/null +++ b/Sources/Modules/Users/UserModule.swift @@ -0,0 +1,69 @@ +// +// File.swift +// +// +// Created by Matt Gardner on 1/26/24. +// + +import Foundation +import OSLog + +internal class UserModule { + let userService = UserService() + + func getUser() async throws -> Knock.User { + do { + 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 { + Knock.shared.log(type: .error, category: .user, message: "getUser", status: .fail, errorMessage: error.localizedDescription) + throw error + } + } + + func updateUser(user: Knock.User) async throws -> Knock.User { + do { + let user = try await userService.updateUser(user: user) + Knock.shared.log(type: .debug, category: .user, message: "updateUser", status: .success) + return user + } catch let error { + Knock.shared.log(type: .error, category: .user, message: "updateUser", status: .fail, errorMessage: error.localizedDescription) + throw error + } + } +} + +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)) + } + } + } +} + diff --git a/Sources/Modules/Users/UserService.swift b/Sources/Modules/Users/UserService.swift index 6889217..57cbd86 100644 --- a/Sources/Modules/Users/UserService.swift +++ b/Sources/Modules/Users/UserService.swift @@ -6,83 +6,19 @@ // import Foundation -import OSLog -public extension Knock { - - private var logger: Logger { - Logger(subsystem: Knock.loggingSubsytem, category: "UserService") - } - - /** - 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() +internal protocol UserServiceProtocol { + func getUser(userId: String) async throws -> Knock.User + func updateUser(user: Knock.User) async throws -> Knock.User +} - - 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)) - } - } - } +internal struct UserService: KnockAPIService, UserServiceProtocol { - func getUser(completionHandler: @escaping ((Result) -> Void)) { - performActionWithUserId( { userId, completion in - self.api.decodeFromGet(User.self, path: "/users/\(userId)", queryItems: nil, then: completion) - }, completionHandler: completionHandler) + internal func getUser(userId: String) async throws -> Knock.User { + try await get(path: "/users/\(userId)", queryItems: nil) } - 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/\(user.id)", body: user) } } diff --git a/Tests/KnockTests/AuthenticationTests.swift b/Tests/KnockTests/AuthenticationTests.swift new file mode 100644 index 0000000..4552e24 --- /dev/null +++ b/Tests/KnockTests/AuthenticationTests.swift @@ -0,0 +1,44 @@ +// +// 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 { + Knock.shared = Knock() + } + + + func testSignIn() async throws { + let userName = "testUserName" + let userToken = "testUserToken" + await Knock.shared.signIn(userId: userName, userToken: userToken) + + XCTAssertEqual(userName, Knock.shared.environment.userId) + XCTAssertEqual(userToken, Knock.shared.environment.userToken) + } + + 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 new file mode 100644 index 0000000..b6bc977 --- /dev/null +++ b/Tests/KnockTests/KnockTests.swift @@ -0,0 +1,56 @@ +// +// KnockTests.swift +// KnockTests +// +// Created by Diego on 19/06/23. +// + +import XCTest +@testable import Knock + +final class KnockTests: XCTestCase { + override func setUpWithError() throws { + try? Knock.shared.setup(publishableKey: "pk_123", pushChannelId: "test") + } + + override func tearDownWithError() throws { + Knock.shared = Knock() + } + + func testPublishableKeyError() throws { + 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 testMakingNetworkRequestBeforeKnockSetUp() async { + try! tearDownWithError() + Knock.shared.environment.setUserInfo(userId: "test", userToken: nil) + 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.knockNotSetup, "The error should be knockNotSetup") + } catch { + XCTFail("Expected KnockError, but received a different error.") + } + } + + 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.") + } + } + + +} diff --git a/KnockTests/KnockTests.swift b/Tests/KnockTests/UserTests.swift similarity index 55% rename from KnockTests/KnockTests.swift rename to Tests/KnockTests/UserTests.swift index b047201..8c55a5c 100644 --- a/KnockTests/KnockTests.swift +++ b/Tests/KnockTests/UserTests.swift @@ -1,35 +1,26 @@ // -// KnockTests.swift -// KnockTests +// UserTests.swift // -// Created by Diego on 19/06/23. +// +// Created by Matt Gardner on 1/31/24. // import XCTest @testable import Knock -final class KnockTests: XCTestCase { - +final class UserTests: XCTestCase { + override func setUpWithError() throws { - // Put setup code here. This method is called before the invocation of each test method in the class. + 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 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 testUserDecoding1() throws { + func testUserDecoding() throws { let decoder = JSONDecoder() let formatter = DateFormatter() @@ -68,13 +59,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 5fe801a298d393df52d7d3fb8db76bc265f6de9d Mon Sep 17 00:00:00 2001 From: Matt Gardner Date: Mon, 12 Feb 2024 15:00:46 -0700 Subject: [PATCH 03/10] [KNO-5189]: Token management and doc updates (#4) * Documentation updates and refactor KnockEnvironment to be and actor to avoid race conditions * adding token handling * readme updates --------- Co-authored-by: Matt Gardner --- Knock.podspec | 4 +- README.md | 144 ++++---------- Sources/Knock.swift | 43 +++-- Sources/KnockAPIService.swift | 20 +- Sources/KnockAppDelegate.swift | 60 ++++-- Sources/KnockEnvironment.swift | 140 ++++++++++---- Sources/KnockErrors.swift | 11 +- Sources/KnockLogger.swift | 6 +- .../Authentication/AuthenticationModule.swift | 49 +++-- Sources/Modules/Channels/ChannelModule.swift | 181 +++++++++++++----- Sources/Modules/Feed/FeedManager.swift | 43 +++-- Sources/Modules/Feed/FeedModule.swift | 14 +- Sources/Modules/Feed/Models/Feed.swift | 1 + .../Modules/Feed/Models/FeedItemScope.swift | 9 +- Sources/Modules/Messages/MessageModule.swift | 49 ++++- .../Messages/Models/KnockMessage.swift | 5 +- .../Messages/Models/KnockMessageStatus.swift | 5 - .../Preferences/Models/PreferenceSet.swift | 31 +++ .../Models/WorkflowPreference.swift | 17 +- .../Preferences/PreferenceModule.swift | 26 +++ Sources/Modules/Users/User.swift | 2 + Sources/Modules/Users/UserModule.swift | 18 ++ Tests/KnockTests/AuthenticationTests.swift | 33 ++-- Tests/KnockTests/ChannelTests.swift | 49 ++++- Tests/KnockTests/KnockTests.swift | 12 +- Tests/KnockTests/UserTests.swift | 8 +- 26 files changed, 652 insertions(+), 328 deletions(-) create mode 100644 Sources/Modules/Preferences/Models/PreferenceSet.swift diff --git a/Knock.podspec b/Knock.podspec index fd9b54c..d7d2b38 100644 --- a/Knock.podspec +++ b/Knock.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |spec| spec.name = "Knock" - spec.version = "0.2.0" + spec.version = "1.0.0" spec.summary = "An SDK to build in-app notifications experiences in Swift with Knock.." spec.description = <<-DESC @@ -16,6 +16,6 @@ Pod::Spec.new do |spec| spec.author = { "Knock" => "support@knock.app" } spec.source = { :git => "https://github.com/knocklabs/knock-swift.git", :tag => "#{spec.version}" } spec.ios.deployment_target = '16.0' - spec.swift_version = '5.0' + spec.swift_version = '5.3' spec.source_files = "Sources/**/*" end diff --git a/README.md b/README.md index f7f8c61..b7d030e 100644 --- a/README.md +++ b/README.md @@ -1,32 +1,28 @@ -# Swift SDK - -## Features - -* Preferences - * getAllUserPreferences - * getUserPreferences - * setUserPreferences -* Channels - * registerTokenForAPNS - * getUserChannelData - * updateUserChannelData -* Messages - * getMessage - * updateMessageStatus - * deleteMessageStatus - * batchUpdateStatuses -* Users - * getUser - * updateUser -## Installation +# Offical Knock iOS SDK + +[![GitHub Release](https://img.shields.io/github/v/release/knocklabs/knock-swift?style=flat)](https://github.com/knocklabs/knock-swift/releases/latest) +[![CocoaPods](https://img.shields.io/cocoapods/v/Knock.svg?style=flat)](https://cocoapods.org/) +[![Carthage compatible](https://img.shields.io/badge/Carthage-compatible-4BC51D.svg?style=flat)](https://github.com/Carthage/Carthage) +[![Swift Package Manager compatible](https://img.shields.io/badge/Swift%20Package%20Manager-compatible-4BC51D.svg?style=flat)](https://swift.org/package-manager/) + +![min swift version is 5.3](https://img.shields.io/badge/min%20Swift%20version-5.3-orange) +![min ios version is 16](https://img.shields.io/badge/min%20iOS%20version-16-blue) +[![GitHub license](https://img.shields.io/badge/license-MIT-lightgrey.svg?style=flat)](https://github.com/knocklabs/ios-example-app/blob/main/LICENSE) + + + +--- + +Knock is a flexible, reliable notifications infrastructure that's built to scale with you. Use our iOS SDK to engage users with in-app feeds, setup push notifications, and manage notification preferences. -You can include the SDK in a couple of ways: +--- -1. Swift Package Manager -2. Carthage -3. Cocoapods -4. Manually +## Documentation + +See the [documentation](https://docs.knock.app/notification-feeds/bring-your-own-ui) for usage examples. + +## Installation ### Swift Package Manager @@ -42,59 +38,20 @@ There are two ways to add this as a dependency using the Swift Package Manager: Screenshot 2023-06-27 at 19 41 32 2. Search for `https://github.com/knocklabs/knock-swift.git` and then click `Add Package` +*Note: We recommend that you set the Dependency Rule to Up to Next Major Version. While we encourage you to keep your app up to date with the latest SDK, major versions can include breaking changes or new features that require your attention.* Screenshot 2023-06-27 at 19 42 09 -3. Ensure that the Package is selected and click `Add Package` - -Screenshot 2023-06-27 at 19 42 23 - -4. Wait for Xcode to fetch the dependencies and you should see the SDK on your Package Dependencies on the sidebar - -Screenshot 2023-06-27 at 19 42 45 - #### Manually via `Package.swift` If you are managing dependencies using the `Package.swift` file, just add this to you dependencies array: ``` swift dependencies: [ - .package(url: "https://github.com/knocklabs/knock-swift.git", .upToNextMajor(from: "0.2.0")) + .package(url: "https://github.com/knocklabs/knock-swift.git", .upToNextMajor(from: "1.0.0")) ] ``` -### Carthage - -1. Add this line to your Cartfile: - -``` -github "knocklabs/knock-swift" ~> 0.2.0 -``` - -2. Run `carthage update`. This will fetch dependencies into a Carthage/Checkouts folder, then build each one or download a pre-compiled framework. -3. Open your application targets’ General settings tab. For Xcode 11.0 and higher, in the "Frameworks, Libraries, and Embedded Content" section, drag and drop each framework you want to use from the Carthage/Build folder on disk. Then, in the "Embed" section, select "Do Not Embed" from the pulldown menu for each item added. For Xcode 10.x and lower, in the "Linked Frameworks and Libraries" section, drag and drop each framework you want to use from the Carthage/Build folder on disk. -4. On your application targets’ Build Phases settings tab, click the + icon and choose New Run Script Phase. Create a Run Script in which you specify your shell (ex: /bin/sh), add the following contents to the script area below the shell: -``` -/usr/local/bin/carthage copy-frameworks -``` -5. Create a file named `input.xcfilelist` and a file named output.xcfilelist -6. Add the paths to the frameworks you want to use to your input.xcfilelist. For example: -``` -$(SRCROOT)/Carthage/Build/iOS/Knock.framework -``` -7. Add the paths to the copied frameworks to the `output.xcfilelist`. For example: -``` -$(BUILT_PRODUCTS_DIR)/$(FRAMEWORKS_FOLDER_PATH)/Result.framework -``` -8. Add the `input.xcfilelist` to the "Input File Lists" section of the Carthage run script phase -9. Add the `output.xcfilelist` to the "Output File Lists" section of the Carthage run script phase - -This script works around an [App Store submission bug](http://www.openradar.me/radar?id=6409498411401216) triggered by universal binaries and ensures that necessary bitcode-related files and dSYMs are copied when archiving. - -With the debug information copied into the built products directory, Xcode will be able to symbolicate the stack trace whenever you stop at a breakpoint. This will also enable you to step through third-party code in the debugger. - -When archiving your application for submission to the App Store or TestFlight, Xcode will also copy these files into the dSYMs subdirectory of your application’s .xcarchive bundle. - ### Cocoapods Add the dependency to your `Podfile`: @@ -108,6 +65,14 @@ target 'MyApp' do end ``` +### Carthage + +1. Add this line to your Cartfile: + +``` +github "knocklabs/knock-swift" ~> 0.2.0 +``` + ### Manually As a last option, you could manually copy the files inside the `Sources` folder to your project. @@ -119,47 +84,20 @@ You can now start using the SDK: ``` swift import Knock -knockClient = try! Knock(publishableKey: "your-pk", userId: "user-id") - -knockClient.getUser{ result in - switch result { - case .success(let user): - print(user) - case .failure(let error): - print(error.localizedDescription) - } -} -``` +// Setup the shared Knock instance as soon as you can. +try? Knock.shared.setup(publishableKey: "your-pk", pushChannelId: "user-id") -## Using the SDK +// Once you know the Knock UserId, sign the user into the shared Knock instance. +await Knock.shared.signIn(userId: "userid", userToken: nil) -The functions of the sdk are encapsulated and managed in a client object. You first have to instantiate a client with your public key and a user id. If you are running on production with enhanced security turned on (recommended) you have to also pass the signed user token to the client constructor. - -``` swift -import Knock - -knockClient = try! Knock(publishableKey: "your-pk", userId: "user-id") - -// on prod with enhanced security turned on: -knockClient = try! Knock(publishableKey: "your-pk", userId: "user-id", userToken: "signed-user-token") ``` -## Notes for publishing - -When releasing a new version of this SDK, please note: - -* You should update the version in a couple of places: - * in the file `Sources/KnockAPI.swift`: `clientVersion = "..."` - * in the file `Knock.podspec`: `spec.version = "..."` - * in this `README.md`, in the installation instructions for all the package managers - * in git, add a tag, preferably to the commit that includes this previous changes - - - - - - +## How to Contribute +Community contributions are welcome! If you'd like to contribute, please read our [contribution guide](CONTRIBUTING.md). +## License +This project is licensed under the MIT license. +See [LICENSE](LICENSE) for more information. diff --git a/Sources/Knock.swift b/Sources/Knock.swift index 6ebbbf1..a656be9 100644 --- a/Sources/Knock.swift +++ b/Sources/Knock.swift @@ -28,16 +28,30 @@ public class Knock { Returns a new instance of the Knock Client - Parameters: - - publishableKey: your public API key + - publishableKey: Your public API key - options: [optional] Options for customizing the Knock instance. */ + public func setup(publishableKey: String, pushChannelId: String?, options: Knock.KnockStartupOptions? = nil) async throws { + logger.loggingDebugOptions = options?.loggingOptions ?? .errorsOnly + try await environment.setPublishableKey(key: publishableKey) + await environment.setBaseUrl(baseUrl: options?.hostname) + await environment.setPushChannelId(pushChannelId) + } + + @available(*, deprecated, message: "Use async setup() method instead for safer handling.") 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: options?.hostname) - environment.pushChannelId = pushChannelId + logger.loggingDebugOptions = options?.loggingOptions ?? .errorsOnly + Task { + try await environment.setPublishableKey(key: publishableKey) + await environment.setBaseUrl(baseUrl: options?.hostname) + await environment.setPushChannelId(pushChannelId) + } } + /** + Reset the current Knock instance entirely. + After calling this, you will need to setup and signin again. + */ public func resetInstanceCompletely() { Knock.shared = Knock() } @@ -45,27 +59,18 @@ public class Knock { public extension Knock { struct KnockStartupOptions { - public init(hostname: String? = nil, debuggingType: DebugOptions = .errorsOnly) { + public init(hostname: String? = nil, loggingOptions: LoggingOptions = .errorsOnly) { self.hostname = hostname - self.debuggingType = debuggingType + self.loggingOptions = loggingOptions } var hostname: String? - var debuggingType: DebugOptions + var loggingOptions: LoggingOptions } - enum DebugOptions { + enum LoggingOptions { case errorsOnly + case errorsAndWarningsOnly case verbose case none } } - -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 6ba2f8f..8eb31f5 100644 --- a/Sources/KnockAPIService.swift +++ b/Sources/KnockAPIService.swift @@ -17,18 +17,22 @@ internal protocol KnockAPIService { } extension KnockAPIService { - private var apiBaseUrl: String { - return "\(Knock.shared.environment.baseUrl)/v1" + + func apiBaseUrl() async -> String { + let base = await Knock.shared.environment.getBaseUrl() + return "\(base)/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) - let loggingMessageSummary = "\(method) \(apiBaseUrl)\(path)" + let baseUrl = await apiBaseUrl() + + let loggingMessageSummary = "\(method) \(baseUrl)\(path)" - guard var URL = URL(string: "\(apiBaseUrl)\(path)") else { - let networkError = Knock.NetworkError(title: "Invalid URL", description: "The URL: \(apiBaseUrl)\(path) is invalid", code: 0) + guard var URL = URL(string: "\(baseUrl)\(path)") else { + let networkError = Knock.NetworkError(title: "Invalid URL", description: "The URL: \(baseUrl)\(path) is invalid", code: 0) Knock.shared.log(type: .warning, category: .networking, message: loggingMessageSummary, status: .fail, errorMessage: networkError.localizedDescription) throw networkError } @@ -52,8 +56,10 @@ extension KnockAPIService { request.addValue("knock-swift@\(Knock.clientVersion)", forHTTPHeaderField: "User-Agent") - request.addValue("Bearer \(try Knock.shared.environment.getSafePublishableKey())", forHTTPHeaderField: "Authorization") - if let userToken = Knock.shared.environment.userToken { + let publishableKey = try await Knock.shared.environment.getSafePublishableKey() + request.addValue("Bearer \(publishableKey)", forHTTPHeaderField: "Authorization") + + if let userToken = await Knock.shared.environment.getUserToken() { request.addValue(userToken, forHTTPHeaderField: "X-Knock-User-Token") } diff --git a/Sources/KnockAppDelegate.swift b/Sources/KnockAppDelegate.swift index 47a3df4..2bd7f0d 100644 --- a/Sources/KnockAppDelegate.swift +++ b/Sources/KnockAppDelegate.swift @@ -9,21 +9,33 @@ import Foundation import UIKit import OSLog +/** +This class serves as an optional base class designed to streamline the integration of Knock into your application. By inheriting from KnockAppDelegate in your AppDelegate, you gain automatic handling of Push Notification registration and device token management, simplifying the initial setup process for Knock's functionalities. + +The class also provides a set of open helper functions that are intended to facilitate the handling of different Push Notification events such as delivery in the foreground, taps, and dismissals. These helper methods offer a straightforward approach to customizing your app's response to notifications, ensuring that you can tailor the behavior to fit your specific needs. + +Override any of the provided methods to achieve further customization, allowing you to control how your application processes and reacts to Push Notifications. Additionally, by leveraging this class, you ensure that your app adheres to best practices for managing device tokens and interacting with the notification system on iOS, enhancing the overall reliability and user experience of your app's notification features. + +Key Features: +- Automatic registration for remote notifications, ensuring your app is promptly set up to receive and handle Push Notifications. +- Simplified device token management, with automatic storage of the device token, facilitating easier access and use in Push Notification payloads. +- Customizable notification handling through open helper functions, allowing for bespoke responses to notification events such as foreground delivery, user taps, and dismissal actions. +- Automatic message status updates, based on Push Notification interaction. + +Developers can benefit from a quick and efficient setup, focusing more on the unique aspects of their notification handling logic while relying on KnockAppDelegate for the foundational setup and management tasks. +*/ + @available(iOSApplicationExtension, unavailable) open class KnockAppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterDelegate { - - // MARK: Init - - public override init() { - super.init() - UIApplication.shared.registerForRemoteNotifications() - UNUserNotificationCenter.current().delegate = self - } + // MARK: Launching - + /// - NOTE: If overriding this function in your AppDelegate, make sure to call super on this to get the default functionality as well. open func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool { - // Check if launched from a notification + UNUserNotificationCenter.current().delegate = self + Knock.shared.requestAndRegisterForPushNotifications() + + // Check if launched from the tap of a notification if let launchOptions = launchOptions, let userInfo = launchOptions[.remoteNotification] as? [String: AnyObject] { Knock.shared.log(type: .error, category: .pushNotification, message: "pushNotificationTapped") @@ -41,14 +53,10 @@ open class KnockAppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificat open func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { Task { - let channelId = Knock.shared.environment.pushChannelId + let channelId = await Knock.shared.environment.getPushChannelId() 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") - } + let _ = try await Knock.shared.channelModule.registerTokenForAPNS(channelId: channelId, token: Knock.convertTokenToString(token: deviceToken)) } 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) } @@ -74,14 +82,30 @@ open class KnockAppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificat pushNotificationDeliveredSilently(userInfo: userInfo, completionHandler: completionHandler) } - // MARK: Convenience methods to make handling incoming push notifications simpler. + // MARK: Helper Functions + /** + Override these functions in your own AppDelegate to simplify push notification handling. + If you want to retain the default logic we provide in these methods, be sure to call the super first. + */ + + public func getMessageId(userInfo: [AnyHashable : Any]) -> String? { + return userInfo["knock_message_id"] as? String + } + open func deviceTokenDidChange(apnsToken: String) {} open func pushNotificationDeliveredInForeground(notification: UNNotification) -> UNNotificationPresentationOptions { + if let messageId = getMessageId(userInfo: notification.request.content.userInfo) { + Knock.shared.updateMessageStatus(messageId: messageId, status: .read) { _ in } + } return [.sound, .badge, .banner] } - open func pushNotificationTapped(userInfo: [AnyHashable : Any]) {} + open func pushNotificationTapped(userInfo: [AnyHashable : Any]) { + if let messageId = getMessageId(userInfo: userInfo) { + Knock.shared.updateMessageStatus(messageId: messageId, status: .interacted) { _ in } + } + } open func pushNotificationDeliveredSilently(userInfo: [AnyHashable : Any], completionHandler: @escaping (UIBackgroundFetchResult) -> Void) { completionHandler(.noData) diff --git a/Sources/KnockEnvironment.swift b/Sources/KnockEnvironment.swift index dbdd0bc..237d849 100644 --- a/Sources/KnockEnvironment.swift +++ b/Sources/KnockEnvironment.swift @@ -7,51 +7,38 @@ import Foundation -internal class KnockEnvironment { +internal actor 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 let previousPushTokensKey = "knock_previous_push_token" - private(set) var userId: String? - private(set) var userToken: String? - private(set) var publishableKey: String? - private(set) var baseUrl: String = defaultBaseUrl - - var userDevicePushToken: String? { - get { - defaults.string(forKey: userDevicePushTokenKey) - } - set { - defaults.set(newValue, forKey: userDevicePushTokenKey) - } - } + private var userId: String? + private var userToken: String? + private var publishableKey: String? + private var pushChannelId: String? + private var baseUrl: String = defaultBaseUrl - var pushChannelId: String? { - get { - defaults.string(forKey: pushChannelIdKey) - } - set { - defaults.set(newValue, forKey: pushChannelIdKey) - } + // BaseURL + + func getBaseUrl() -> String { + baseUrl } - - func setPublishableKey(key: String) throws { - guard key.hasPrefix("sk_") == false else { - let error = Knock.KnockError.wrongKeyError - Knock.shared.log(type: .error, category: .general, message: "setPublishableKey", status: .fail, errorMessage: error.localizedDescription) - throw error - } - self.publishableKey = key + + func setBaseUrl(baseUrl: String?) { + self.baseUrl = "\(baseUrl ?? KnockEnvironment.defaultBaseUrl)" } + //UserId + func setUserInfo(userId: String?, userToken: String?) { self.userId = userId self.userToken = userToken } - func setBaseUrl(baseUrl: String?) { - self.baseUrl = "\(baseUrl ?? KnockEnvironment.defaultBaseUrl)" + func getUserId() -> String? { + userId } func getSafeUserId() throws -> String { @@ -61,10 +48,99 @@ internal class KnockEnvironment { return id } + func getUserToken() -> String? { + userToken + } + + func getSafeUserToken() throws -> String? { + guard let token = userToken else { + throw Knock.KnockError.userTokenNotSet + } + return token + } + + // Publishable Key + func setPublishableKey(key: String) throws { + guard key.hasPrefix("sk_") == false else { + let error = Knock.KnockError.wrongKeyError + Knock.shared.log(type: .error, category: .general, message: "setPublishableKey", status: .fail, errorMessage: error.localizedDescription) + throw error + } + self.publishableKey = key + } + + func getPublishableKey() -> String? { + publishableKey + } + func getSafePublishableKey() throws -> String { guard let id = publishableKey else { throw Knock.KnockError.knockNotSetup } return id } + + // PushChannelId + func setPushChannelId(_ newChannelId: String?) { + self.pushChannelId = newChannelId + } + + func getPushChannelId() -> String? { + self.pushChannelId + } + + func getSafePushChannelId() throws -> String { + guard let id = pushChannelId else { + throw Knock.KnockError.pushChannelIdNotSetError + } + return id + } + + // APNS Device Token + +// public func setDeviceToken(_ token: String?) async { +// let currentToken = getDeviceToken() +// if currentToken != token { +// var previousTokens = await getPreviousPushTokens() +// var tokenSet: Set = Set(previousTokens) +// if let currentToken = currentToken { +// tokenSet.insert(currentToken) +// } +// if let token = token { +// tokenSet.insert(token) +// } +// setPreviousPushTokens(tokens: Array(tokenSet)) +// defaults.set(token, forKey: userDevicePushTokenKey) +// } +// } + + public func setDeviceToken(_ token: String?) async { + let previousTokens = getPreviousPushTokens() + if let token = token, !previousTokens.contains(token) { + // Append new token to the list of previous tokens only if it's unique + // We are storing these old tokens so that we can ensure they get unregestired. + setPreviousPushTokens(tokens: previousTokens + [token]) + } + + // Update the current device token + defaults.set(token, forKey: userDevicePushTokenKey) + } + + func getDeviceToken() -> String? { + defaults.string(forKey: userDevicePushTokenKey) + } + + func getSafeDeviceToken() throws -> String { + guard let token = getDeviceToken() else { + throw Knock.KnockError.devicePushTokenNotSet + } + return token + } + + func setPreviousPushTokens(tokens: [String]) { + defaults.set(tokens, forKey: previousPushTokensKey) + } + func getPreviousPushTokens() -> [String] { + defaults.array(forKey: previousPushTokensKey) as? [String] ?? [] + } } diff --git a/Sources/KnockErrors.swift b/Sources/KnockErrors.swift index cb06e1b..36890a2 100644 --- a/Sources/KnockErrors.swift +++ b/Sources/KnockErrors.swift @@ -11,6 +11,9 @@ public extension Knock { enum KnockError: Error, Equatable { case runtimeError(String) case userIdNotSetError + case userTokenNotSet + case devicePushTokenNotSet + case pushChannelIdNotSetError case knockNotSetup case wrongKeyError } @@ -37,7 +40,13 @@ extension Knock.KnockError: LocalizedError { case .runtimeError(let message): return message case .userIdNotSetError: - return "UserId not found. Please authenticate your userId with Knock.signIn()." + return "UserId not found. Please authenticate your userId with Knock.shared.signIn()." + case .userTokenNotSet: + return "User token must be set for production environments. Please authenticate your user toekn with Knock.shared.signIn()." + case .pushChannelIdNotSetError: + return "PushChannelId not found. Please setup with Knock.shared.setup() or Knock.shared.registerTokenForAPNS()." + case .devicePushTokenNotSet: + return "Device Push Notification token not found. Please setup with Knock.shared.registerTokenForAPNS()." case .knockNotSetup: return "Knock instance still needs to be setup. Please setup with Knock.shared.setup()." case .wrongKeyError: diff --git a/Sources/KnockLogger.swift b/Sources/KnockLogger.swift index 5f5ea72..b9df9c6 100644 --- a/Sources/KnockLogger.swift +++ b/Sources/KnockLogger.swift @@ -11,7 +11,7 @@ import os.log internal class KnockLogger { private static let loggingSubsytem = "knock-swift" - internal var loggingDebugOptions: Knock.DebugOptions = .errorsOnly + internal var loggingDebugOptions: Knock.LoggingOptions = .errorsOnly internal func log(type: LogType, category: LogCategory, message: String, description: String? = nil, status: LogStatus? = nil, errorMessage: String? = nil, additionalInfo: [String: String]? = nil) { switch loggingDebugOptions { @@ -19,6 +19,10 @@ internal class KnockLogger { if type != .error { return } + case .errorsAndWarningsOnly: + if type != .error || type != .warning { + return + } case .verbose: break case .none: diff --git a/Sources/Modules/Authentication/AuthenticationModule.swift b/Sources/Modules/Authentication/AuthenticationModule.swift index 3f922a6..c5dac1e 100644 --- a/Sources/Modules/Authentication/AuthenticationModule.swift +++ b/Sources/Modules/Authentication/AuthenticationModule.swift @@ -8,11 +8,11 @@ import Foundation internal class AuthenticationModule { - + func signIn(userId: String, userToken: String?) async { - Knock.shared.environment.setUserInfo(userId: userId, userToken: userToken) + await Knock.shared.environment.setUserInfo(userId: userId, userToken: userToken) - if let token = Knock.shared.environment.userDevicePushToken, let channelId = Knock.shared.environment.pushChannelId { + if let token = await Knock.shared.environment.getDeviceToken(), let channelId = await Knock.shared.environment.getPushChannelId() { do { let _ = try await Knock.shared.channelModule.registerTokenForAPNS(channelId: channelId, token: token) } catch { @@ -24,35 +24,49 @@ internal class AuthenticationModule { } func signOut() async throws { - guard let channelId = Knock.shared.environment.pushChannelId, let token = Knock.shared.environment.userDevicePushToken else { - clearDataForSignOut() + guard let channelId = await Knock.shared.environment.getPushChannelId(), let token = await Knock.shared.environment.getDeviceToken() else { + await clearDataForSignOut() return } let _ = try await Knock.shared.channelModule.unregisterTokenForAPNS(channelId: channelId, token: token) - clearDataForSignOut() + await clearDataForSignOut() return } - func clearDataForSignOut() { - Knock.shared.environment.setUserInfo(userId: nil, userToken: nil) + func clearDataForSignOut() async { + await Knock.shared.environment.setUserInfo(userId: nil, userToken: nil) } } public extension Knock { - - func isAuthenticated(checkUserToken: Bool = false) -> Bool { - let isUser = Knock.shared.environment.userId?.isEmpty == false + /** + Convienience method to determine if a user is currently authenticated for the Knock instance. + */ + func isAuthenticated(checkUserToken: Bool = false) async -> Bool { + let isUser = await Knock.shared.environment.getUserId()?.isEmpty == false if checkUserToken { - return isUser && Knock.shared.environment.userToken?.isEmpty == false + let hasToken = await Knock.shared.environment.getUserToken()?.isEmpty == false + return isUser && hasToken } return isUser } + func isAuthenticated(checkUserToken: Bool = false, completionHandler: @escaping ((Bool) -> Void)) { + Task { + completionHandler(await isAuthenticated(checkUserToken: checkUserToken)) + } + } + /** - 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 + Sets the userId and userToken for the current Knock instance. + If the device token and pushChannelId were set previously, this will also attempt to register the token to the user that is being signed in. + This does not get the user from the database nor does it return the full User object. + You should consider using this in areas where you update your local user's state. + + - Parameters: + - userId: The id of the Knock channel to lookup. + - userToken: [optional] The id of the Knock channel to lookup. */ func signIn(userId: String, userToken: String?) async { await authenticationModule.signIn(userId: userId, userToken: userToken) @@ -66,9 +80,10 @@ public extension Knock { } /** - Clears the current user id and access token + Sets the userId and userToken for the current Knock instance back to nil. + If the device token and pushChannelId were set previously, this will also attempt to unregister the token to the user that is being signed out so they don't receive pushes they shouldn't get. 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 + - Note: This will not clear the device token so that it can be accesed for the next user to login. */ func signOut() async throws { try await authenticationModule.signOut() diff --git a/Sources/Modules/Channels/ChannelModule.swift b/Sources/Modules/Channels/ChannelModule.swift index 917a026..7f3694b 100644 --- a/Sources/Modules/Channels/ChannelModule.swift +++ b/Sources/Modules/Channels/ChannelModule.swift @@ -38,72 +38,130 @@ internal class ChannelModule { } } - private func registerOrUpdateToken(token: String, channelId: String, existingTokens: [String]?) async throws -> Knock.ChannelData { - var tokens = existingTokens ?? [] - if !tokens.contains(token) { - tokens.append(token) + // MARK: APNS Device Token Registration + + func registerTokenForAPNS(channelId: String?, token: String) async throws -> Knock.ChannelData { + // Store or update the token locally immediately + await Knock.shared.environment.setDeviceToken(token) + + // Ensure user is authenticated + guard let channelId = channelId, await Knock.shared.isAuthenticated() else { + // Exit if not authenticated; token is stored for later registration + 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().") + return .init(channel_id: "", data: ["tokens": [token]]) } - let data: AnyEncodable = ["tokens": tokens] - let channelData = try await updateUserChannelData(channelId: channelId, data: data) - Knock.shared.log(type: .debug, category: .pushNotification, message: "registerOrUpdateToken", status: .success) - return channelData + await Knock.shared.environment.setPushChannelId(channelId) + + // Now proceed to prepare and register the token on the server + return try await prepareToRegisterTokenOnServer(token: token, channelId: channelId) } - func registerTokenForAPNS(channelId: String, token: String) async throws -> Knock.ChannelData { - Knock.shared.environment.pushChannelId = channelId - Knock.shared.environment.userDevicePushToken = token - + private func prepareToRegisterTokenOnServer(token: String, channelId: 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) - } + // Retrieve existing channel data to prepare token update + let existingChannelData = try await getUserChannelData(channelId: channelId) + let existingChannelTokens = existingChannelData.data?["tokens"]?.value as? [String] ?? [] - if tokens.contains(token) { - // Token already registered - Knock.shared.log(type: .debug, category: .pushNotification, message: "registerTokenForAPNS", status: .success) - return channelData + // Retrieve old tokens that have not yet been deregistered + let previousTokens = await Knock.shared.environment.getPreviousPushTokens() + + // Filter and prepare tokens for registration + let preparedTokens = getTokenDataForServer( + newToken: token, + previousTokens: previousTokens, + channelDataTokens: existingChannelTokens + ) + + // Proceed with updating the server if there are changes + if preparedTokens != existingChannelTokens { + return try await registerNewTokenDataOnServer(tokens: preparedTokens, channelId: channelId) } else { - // Register the new token - return try await registerOrUpdateToken(token: token, channelId: channelId, existingTokens: tokens) + return existingChannelData } } catch let userIdError as Knock.KnockError where userIdError == Knock.KnockError.userIdNotSetError { + // User is not signed in. This should be caught earlier, but including it here as well just in case. 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 { + return .init(channel_id: channelId, data: ["tokens": [token]]) + } catch let networkError as Knock.NetworkError where networkError.code == 404 { // 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) + return try await registerNewTokenDataOnServer(tokens: [token], channelId: channelId) + } catch { + Knock.shared.log(type: .error, category: .pushNotification, message: "Failed to register token", errorMessage: error.localizedDescription) + throw error } } + private func registerNewTokenDataOnServer(tokens: [String], channelId: String) async throws -> Knock.ChannelData { + let data: AnyEncodable = ["tokens": tokens] + let newChannelData = try await updateUserChannelData(channelId: channelId, data: data) + + // Clear previous tokens upon successful update + await Knock.shared.environment.setPreviousPushTokens(tokens: []) + + Knock.shared.log(type: .debug, category: .pushNotification, message: "Token registered on server", status: .success) + return newChannelData + } + + internal func getTokenDataForServer( + newToken: String, + previousTokens: [String], + channelDataTokens: [String], + forDeregistration: Bool = false + ) -> [String] { + var updatedTokens = channelDataTokens + + // Filter out any tokens from existingTokens that are also in previousTokens + updatedTokens.removeAll(where: { previousTokens.contains($0) }) + + // Add the new token if it's not already in the list + if forDeregistration { + updatedTokens.removeAll(where: { $0 == newToken }) + } else if !updatedTokens.contains(newToken) { + updatedTokens.append(newToken) + } + + return updatedTokens + } + + + // MARK: APNS Device Token UnRegistration + 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 { + let previousTokens = await Knock.shared.environment.getPreviousPushTokens() + + guard let tokens = channelData.data?["tokens"]?.value as? [String] else { // No valid tokens array found. 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 } - if tokens.contains(token) { + let updatedTokens = getTokenDataForServer(newToken: token, previousTokens: previousTokens, channelDataTokens: tokens, forDeregistration: true) + + if updatedTokens != tokens { let newTokensSet = Set(tokens).subtracting([token]) let newTokens = Array(newTokensSet) let data: AnyEncodable = [ "tokens": newTokens ] + let updateData = try await updateUserChannelData(channelId: channelId, data: data) + + // Clear previous tokens upon successful update + await Knock.shared.environment.setPreviousPushTokens(tokens: []) + Knock.shared.log(type: .debug, category: .pushNotification, message: "unregisterTokenForAPNS", status: .success) return updateData } else { - 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.") + Knock.shared.log(type: .debug, 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 - 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.") + Knock.shared.log(type: .debug, 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. @@ -116,6 +174,13 @@ internal class ChannelModule { public extension Knock { + /** + Retrieves the channel data for the current user on the channel specified. + https://docs.knock.app/reference#get-user-channel-data#get-user-channel-data + + - Parameters: + - channelId: The id of the Knock channel to lookup. + */ func getUserChannelData(channelId: String) async throws -> ChannelData { try await self.channelModule.getUserChannelData(channelId: channelId) } @@ -135,7 +200,7 @@ public extension Knock { Sets channel data for the user and the channel specified. - Parameters: - - channelId: the id of the channel + - channelId: The id of the Knock channel to lookup - 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 { @@ -156,12 +221,27 @@ public extension Knock { // 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. + Returns the apnsDeviceToekn that was set from the Knock.shared.registerTokenForAPNS. + If you use our KnockAppDelegate, the token registration will be handled for you automatically. + */ + func getApnsDeviceToken() async -> String? { + await environment.getDeviceToken() + } + + func getApnsDeviceToken(completion: @escaping (String?) -> Void) { + Task { + completion(await environment.getDeviceToken()) + } + } + + /** + 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. + If the new token differs from the last token that was used on the device, the old token will be unregistered. 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` @@ -181,17 +261,6 @@ public extension Knock { } } - /** - 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) @@ -212,7 +281,6 @@ public extension Knock { - 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) } @@ -240,6 +308,9 @@ public extension Knock { unregisterTokenForAPNS(channelId: channelId, token: tokenString, completionHandler: completionHandler) } + /** + Convenience method to determine whether or not the user is allowing Push Notifications for the app. + */ func getNotificationPermissionStatus(completion: @escaping (UNAuthorizationStatus) -> Void) { channelModule.userNotificationCenter.getNotificationSettings(completionHandler: { settings in completion(settings.authorizationStatus) @@ -251,6 +322,9 @@ public extension Knock { return settings.authorizationStatus } + /** + Convenience method to request Push Notification permissions for the app. + */ func requestNotificationPermission(options: UNAuthorizationOptions = [.sound, .badge, .alert], completion: @escaping (UNAuthorizationStatus) -> Void) { channelModule.userNotificationCenter.requestAuthorization( options: options, @@ -266,4 +340,17 @@ public extension Knock { try await channelModule.userNotificationCenter.requestAuthorization(options: options) return await getNotificationPermissionStatus() } + + /** + Convenience method to request Push Notification permissions for the app, and then, if successfull, registerForRemoteNotifications in order to get a device token. + */ + func requestAndRegisterForPushNotifications() { + Knock.shared.requestNotificationPermission { status in + if status != .denied { + DispatchQueue.main.async { + UIApplication.shared.registerForRemoteNotifications() + } + } + } + } } diff --git a/Sources/Modules/Feed/FeedManager.swift b/Sources/Modules/Feed/FeedManager.swift index 84a56ac..48c8214 100644 --- a/Sources/Modules/Feed/FeedManager.swift +++ b/Sources/Modules/Feed/FeedManager.swift @@ -13,15 +13,22 @@ import UIKit public extension Knock { class FeedManager { - private let feedModule: FeedModule + private var 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) + public init(feedId: String, options: FeedClientOptions = FeedClientOptions(archived: .exclude)) async throws { + self.feedModule = try await FeedModule(feedId: feedId, options: options) registerForAppLifecycleNotifications() } + public init(feedId: String, options: FeedClientOptions = FeedClientOptions(archived: .exclude)) throws { + Task { + self.feedModule = try await FeedModule(feedId: feedId, options: options) + registerForAppLifecycleNotifications() + } + } + deinit { unregisterFromAppLifecycleNotifications() } @@ -45,11 +52,19 @@ public extension Knock { } } + private func didEnterForeground() { + Knock.shared.feedManager?.connectToFeed() + } + + private func didEnterBackground() { + Knock.shared.feedManager?.disconnectFromFeed() + } + /** Connect to the feed via socket. This will initialize the connection. You should also call the `on(eventName, completionHandler)` function to delegate what should be executed on certain received events and the `disconnectFromFeed()` function to terminate the connection. - Parameters: - - options: options of type `FeedClientOptions` to merge with the default ones (set on the constructor) and scope as much as possible the results + - options: [optional] 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) { feedModule.connectToFeed(options: options) @@ -64,11 +79,10 @@ public extension Knock { } /** - Gets the content of the user feed - + Retrieves a feed of items in reverse chronological order + - Parameters: - - 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 + - options: [optional] Options of type `FeedClientOptions` to merge with the default ones (set on the constructor) and scope as much as possible the results */ public func getUserFeedContent(options: FeedClientOptions? = nil) async throws -> Feed { try await self.feedModule.getUserFeedContent(options: options) @@ -91,9 +105,8 @@ public extension Knock { - Attention: The base scope for the call should take into account all of the options currently set on the feed, as well as being scoped for the current user. We do this so that we **ONLY** make changes to the messages that are currently in view on this feed, and not all messages that exist. - Parameters: - - type: the kind of update - - 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 + - type: The kind of update + - options: All the options currently set on the feed to scope as much as possible the bulk update */ public func makeBulkStatusUpdate(type: BulkChannelMessageStatusUpdateType, options: FeedClientOptions) async throws -> BulkOperation { try await feedModule.makeBulkStatusUpdate(type: type, options: options) @@ -109,13 +122,5 @@ 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 d850403..00dc1dd 100644 --- a/Sources/Modules/Feed/FeedModule.swift +++ b/Sources/Modules/Feed/FeedModule.swift @@ -17,19 +17,22 @@ internal class FeedModule { private var feedOptions: Knock.FeedClientOptions private let feedService = FeedService() - internal init(feedId: String, options: Knock.FeedClientOptions) throws { + internal init(feedId: String, options: Knock.FeedClientOptions) async throws { // use regex and circumflex accent to mark only the starting http to be replaced and not any others - let websocketHostname = Knock.shared.environment.baseUrl.replacingOccurrences(of: "^http", with: "ws", options: .regularExpression) // default: wss://api.knock.app + let base = await Knock.shared.environment.getBaseUrl() + let websocketHostname = base.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 Knock.shared.environment.getSafeUserId() + userId = try await Knock.shared.environment.getSafeUserId() } catch let error { Knock.shared.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": try Knock.shared.environment.getSafePublishableKey(), "user_token": Knock.shared.environment.userToken ?? ""]) + let userToken = await Knock.shared.environment.getUserToken() + let publishableKey = try await Knock.shared.environment.getSafePublishableKey() + self.socket = Socket(websocketPath, params: ["vsn": "2.0.0", "api_key": publishableKey, "user_token": userToken ?? ""]) self.feedId = feedId self.feedTopic = "feeds:\(feedId):\(userId)" self.feedOptions = options @@ -70,7 +73,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 Knock.shared.environment.getSafeUserId() + let userId = try await Knock.shared.environment.getSafeUserId() let body: AnyEncodable = [ "user_ids": [userId], "engagement_status": options.status != nil && options.status != .all ? options.status!.rawValue : "", @@ -131,7 +134,6 @@ internal class FeedModule { } } - // TODO: Determine the level of logging we want from SwiftPhoenixClient. Currently this produces a lot of noise. socket.logger = { msg in Knock.shared.log(type: .debug, category: .feed, message: "SwiftPhoenixClient", description: msg) } diff --git a/Sources/Modules/Feed/Models/Feed.swift b/Sources/Modules/Feed/Models/Feed.swift index ff957b8..eab2db5 100644 --- a/Sources/Modules/Feed/Models/Feed.swift +++ b/Sources/Modules/Feed/Models/Feed.swift @@ -9,6 +9,7 @@ import Foundation public extension Knock { + // https://docs.knock.app/reference#get-feed#feeds struct Feed: Codable { public var entries: [FeedItem] = [] public var meta: FeedMetadata = FeedMetadata() diff --git a/Sources/Modules/Feed/Models/FeedItemScope.swift b/Sources/Modules/Feed/Models/FeedItemScope.swift index e3688f6..a5232aa 100644 --- a/Sources/Modules/Feed/Models/FeedItemScope.swift +++ b/Sources/Modules/Feed/Models/FeedItemScope.swift @@ -10,12 +10,9 @@ import Foundation extension Knock { 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 archived + case unarchived + case interacted case all case unread case read diff --git a/Sources/Modules/Messages/MessageModule.swift b/Sources/Modules/Messages/MessageModule.swift index 7b19c88..c339eca 100644 --- a/Sources/Modules/Messages/MessageModule.swift +++ b/Sources/Modules/Messages/MessageModule.swift @@ -57,6 +57,13 @@ internal class MessageModule { public extension Knock { + /** + Returns the KnockMessage for the associated messageId. + https://docs.knock.app/reference#get-a-message + + - Parameters: + - messageId: The messageId for the KnockMessage. + */ func getMessage(messageId: String) async throws -> KnockMessage { try await self.messageModule.getMessage(messageId: messageId) } @@ -72,6 +79,14 @@ public extension Knock { } } + /** + Marks the given message with the provided status, recording an event in the process. + https://docs.knock.app/reference#update-message-status + + - Parameters: + - message: The KnockMessage that you want to update. + - status: The new status to be associated with the KnockMessage. + */ func updateMessageStatus(message: KnockMessage, status: KnockMessageStatusUpdateType) async throws -> KnockMessage { try await self.messageModule.updateMessageStatus(messageId: message.id, status: status) } @@ -87,6 +102,14 @@ public extension Knock { } } + /** + Marks the given message with the provided status, recording an event in the process. + https://docs.knock.app/reference#update-message-status + + - Parameters: + - messageId: The id for the KnockMessage that you want to update. + - status: The new status to be associated with the KnockMessage. + */ func updateMessageStatus(messageId: String, status: KnockMessageStatusUpdateType) async throws -> KnockMessage { try await self.messageModule.updateMessageStatus(messageId: messageId, status: status) } @@ -102,6 +125,14 @@ public extension Knock { } } + /** + Un-marks the given status on a message, recording an event in the process. + https://docs.knock.app/reference#undo-message-status + + - Parameters: + - message: The KnockMessage that you want to update. + - status: The new status to be associated with the KnockMessage. + */ func deleteMessageStatus(message: KnockMessage, status: KnockMessageStatusUpdateType) async throws -> KnockMessage { try await self.messageModule.deleteMessageStatus(messageId: message.id, status: status) } @@ -117,8 +148,16 @@ public extension Knock { } } + /** + Un-marks the given status on a message, recording an event in the process. + https://docs.knock.app/reference#undo-message-status + + - Parameters: + - preferenceId: The preferenceId for the PreferenceSet. + - preferenceSet: PreferenceSet with updated properties. + */ func deleteMessageStatus(messageId: String, status: KnockMessageStatusUpdateType) async throws -> KnockMessage { - try await self.messageModule.updateMessageStatus(messageId: messageId, status: status) + try await self.messageModule.deleteMessageStatus(messageId: messageId, status: status) } func deleteMessageStatus(messageId: String, status: KnockMessageStatusUpdateType, completionHandler: @escaping ((Result) -> Void)) { @@ -135,11 +174,13 @@ public extension Knock { /** Batch status update for a list of messages + https://docs.knock.app/reference#batch-update-message-status - 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 + + *Note:* Knock scopes this batch rate limit by message_ids and status. This allows for 1 update per second per message per status. */ func batchUpdateStatuses(messageIds: [String], status: KnockMessageStatusBatchUpdateType) async throws -> [KnockMessage] { try await self.messageModule.batchUpdateStatuses(messageIds: messageIds, status: status) @@ -158,11 +199,13 @@ public extension Knock { /** Batch status update for a list of messages + https://docs.knock.app/reference#batch-update-message-status - Parameters: - messages: the list of messages `[KnockMessage]` to be updated - status: the new `Status` - - completionHandler: the code to execute when the response is received + + *Note:* Knock scopes this batch rate limit by message_ids and status. This allows for 1 update per second per message per status. */ func batchUpdateStatuses(messages: [KnockMessage], status: KnockMessageStatusBatchUpdateType) async throws -> [KnockMessage] { let messageIds = messages.map{$0.id} diff --git a/Sources/Modules/Messages/Models/KnockMessage.swift b/Sources/Modules/Messages/Models/KnockMessage.swift index 44dafbb..9b7bc04 100644 --- a/Sources/Modules/Messages/Models/KnockMessage.swift +++ b/Sources/Modules/Messages/Models/KnockMessage.swift @@ -8,6 +8,7 @@ import Foundation public extension Knock { + // https://docs.knock.app/reference#messages#feeds // Named `KnockMessage` and not only `Message` to avoid a name colission to the type in `SwiftPhoenixClient` struct KnockMessage: Codable { @@ -37,8 +38,8 @@ public extension Knock { public let interacted_at: Date? public let link_clicked_at: Date? public let archived_at: Date? - // public let inserted_at: Date? // check datetime format, it differs from the others - // public let updated_at: Date? // check datetime format, it differs from the others + public let inserted_at: Date? + public let updated_at: Date? public let source: WorkflowSource public let data: [String: AnyCodable]? // GenericData } diff --git a/Sources/Modules/Messages/Models/KnockMessageStatus.swift b/Sources/Modules/Messages/Models/KnockMessageStatus.swift index 5279b1c..5227f66 100644 --- a/Sources/Modules/Messages/Models/KnockMessageStatus.swift +++ b/Sources/Modules/Messages/Models/KnockMessageStatus.swift @@ -16,12 +16,7 @@ public extension Knock { case delivery_attempted case undelivered case seen -// case read -// case interacted -// case archived case unseen -// case unread -// case unarchived } enum KnockMessageEngagementStatus: String, Codable { diff --git a/Sources/Modules/Preferences/Models/PreferenceSet.swift b/Sources/Modules/Preferences/Models/PreferenceSet.swift new file mode 100644 index 0000000..0c09657 --- /dev/null +++ b/Sources/Modules/Preferences/Models/PreferenceSet.swift @@ -0,0 +1,31 @@ +// +// File.swift +// +// +// Created by Matt Gardner on 2/6/24. +// + +import Foundation + + +public extension Knock { + + //https://docs.knock.app/reference#preferences#preferences + + struct PreferenceSet: Codable { + public var id: String? = nil // default or tenant.id; TODO: check this, because the API allows any value to be used here, not only default and an existing tenant.id + public var channel_types: ChannelTypePreferences = ChannelTypePreferences() + public var workflows: [String: Either] = [:] + public var categories: [String: Either] = [:] + + public init(from decoder: Decoder) throws { + let container: KeyedDecodingContainer = try decoder.container(keyedBy: Knock.PreferenceSet.CodingKeys.self) + self.id = try container.decodeIfPresent(String.self, forKey: Knock.PreferenceSet.CodingKeys.id) + self.channel_types = try container.decodeIfPresent(Knock.ChannelTypePreferences.self, forKey: Knock.PreferenceSet.CodingKeys.channel_types) ?? ChannelTypePreferences() + self.workflows = try container.decodeIfPresent([String : Either].self, forKey: Knock.PreferenceSet.CodingKeys.workflows) ?? [:] + self.categories = try container.decodeIfPresent([String : Either].self, forKey: Knock.PreferenceSet.CodingKeys.categories) ?? [:] + } + + public init() {} + } +} diff --git a/Sources/Modules/Preferences/Models/WorkflowPreference.swift b/Sources/Modules/Preferences/Models/WorkflowPreference.swift index a14109f..f31d9d8 100644 --- a/Sources/Modules/Preferences/Models/WorkflowPreference.swift +++ b/Sources/Modules/Preferences/Models/WorkflowPreference.swift @@ -25,22 +25,7 @@ public extension Knock { } } - struct PreferenceSet: Codable { - public var id: String? = nil // default or tenant.id; TODO: check this, because the API allows any value to be used here, not only default and an existing tenant.id - public var channel_types: ChannelTypePreferences = ChannelTypePreferences() - public var workflows: [String: Either] = [:] - public var categories: [String: Either] = [:] - - public init(from decoder: Decoder) throws { - let container: KeyedDecodingContainer = try decoder.container(keyedBy: Knock.PreferenceSet.CodingKeys.self) - self.id = try container.decodeIfPresent(String.self, forKey: Knock.PreferenceSet.CodingKeys.id) - self.channel_types = try container.decodeIfPresent(Knock.ChannelTypePreferences.self, forKey: Knock.PreferenceSet.CodingKeys.channel_types) ?? ChannelTypePreferences() - self.workflows = try container.decodeIfPresent([String : Either].self, forKey: Knock.PreferenceSet.CodingKeys.workflows) ?? [:] - self.categories = try container.decodeIfPresent([String : Either].self, forKey: Knock.PreferenceSet.CodingKeys.categories) ?? [:] - } - - public init() {} - } + struct WorkflowPreferenceBoolItem: Identifiable, Equatable { public var id: String diff --git a/Sources/Modules/Preferences/PreferenceModule.swift b/Sources/Modules/Preferences/PreferenceModule.swift index 9cc0c1e..b093ed2 100644 --- a/Sources/Modules/Preferences/PreferenceModule.swift +++ b/Sources/Modules/Preferences/PreferenceModule.swift @@ -46,6 +46,12 @@ internal class PreferenceModule { } public extension Knock { + + /** + Retrieve all user's preference sets. Will always return an empty preference set object, even if it does not currently exist for the user. + https://docs.knock.app/reference#get-preferences-user#get-preferences-user + + */ func getAllUserPreferences() async throws -> [Knock.PreferenceSet] { try await self.preferenceModule.getAllUserPreferences() } @@ -61,6 +67,13 @@ public extension Knock { } } + /** + Retrieve a user's preference set. Will always return an empty preference set object, even if it does not currently exist for the user. + https://docs.knock.app/reference#get-preferences-user#get-preferences-user + + - Parameters: + - preferenceId: The preferenceId for the PreferenceSet. + */ func getUserPreferences(preferenceId: String) async throws -> Knock.PreferenceSet { try await self.preferenceModule.getUserPreferences(preferenceId: preferenceId) } @@ -76,6 +89,19 @@ public extension Knock { } } + /** + Sets preferences within the given preference set. This is a destructive operation and will replace any existing preferences with the preferences given. + + If no user exists in the current environment for the current user, Knock will create the user entry as part of this request. + + The preference set :id can be either "default" or a tenant.id. Learn more about per-tenant preference sets in our preferences guide. + https://docs.knock.app/send-and-manage-data/preferences#preference-sets + https://docs.knock.app/reference#get-preferences-user#set-preferences-user + + - Parameters: + - preferenceId: The preferenceId for the PreferenceSet. + - preferenceSet: PreferenceSet with updated properties. + */ func setUserPreferences(preferenceId: String, preferenceSet: PreferenceSet) async throws -> Knock.PreferenceSet { try await self.preferenceModule.setUserPreferences(preferenceId: preferenceId, preferenceSet: preferenceSet) } diff --git a/Sources/Modules/Users/User.swift b/Sources/Modules/Users/User.swift index 9b5ef1b..f409f43 100644 --- a/Sources/Modules/Users/User.swift +++ b/Sources/Modules/Users/User.swift @@ -10,6 +10,8 @@ import Foundation public extension Knock { // MARK: Users + // https://docs.knock.app/reference#users#users + struct User: Codable { public let id: String public let name: String? diff --git a/Sources/Modules/Users/UserModule.swift b/Sources/Modules/Users/UserModule.swift index ff06b1a..0811417 100644 --- a/Sources/Modules/Users/UserModule.swift +++ b/Sources/Modules/Users/UserModule.swift @@ -36,6 +36,21 @@ internal class UserModule { public extension Knock { + /// Returns the userId that was set from the Knock.shared.signIn method. + func getUserId() async -> String? { + await environment.getUserId() + } + + func getUserId(completion: @escaping (String?) -> Void) { + Task { + completion(await environment.getUserId()) + } + } + + /** + Retrieve the current user, including all properties previously set. + https://docs.knock.app/reference#get-user#get-user + */ func getUser() async throws -> User { return try await userModule.getUser() } @@ -51,6 +66,9 @@ public extension Knock { } } + /** + Updates the current user and returns the updated User result. + */ func updateUser(user: User) async throws -> User { return try await userModule.updateUser(user: user) } diff --git a/Tests/KnockTests/AuthenticationTests.swift b/Tests/KnockTests/AuthenticationTests.swift index 4552e24..851f919 100644 --- a/Tests/KnockTests/AuthenticationTests.swift +++ b/Tests/KnockTests/AuthenticationTests.swift @@ -11,11 +11,13 @@ import XCTest final class AuthenticationTests: XCTestCase { override func setUpWithError() throws { - try? Knock.shared.setup(publishableKey: "pk_123", pushChannelId: "test") + Task { + try? await Knock.shared.setup(publishableKey: "pk_123", pushChannelId: "test") + } } override func tearDownWithError() throws { - Knock.shared = Knock() + Knock.shared.resetInstanceCompletely() } @@ -23,22 +25,27 @@ final class AuthenticationTests: XCTestCase { let userName = "testUserName" let userToken = "testUserToken" await Knock.shared.signIn(userId: userName, userToken: userToken) - - XCTAssertEqual(userName, Knock.shared.environment.userId) - XCTAssertEqual(userToken, Knock.shared.environment.userToken) + + let knockUserName = await Knock.shared.environment.getUserId() + let knockUserToken = await Knock.shared.environment.getUserToken() + XCTAssertEqual(userName, knockUserName) + XCTAssertEqual(userToken, knockUserToken) } 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" + await Knock.shared.environment.setDeviceToken("test") + + await Knock.shared.authenticationModule.clearDataForSignOut() - Knock.shared.authenticationModule.clearDataForSignOut() + let userId = await Knock.shared.environment.getUserId() + let userToken = await Knock.shared.environment.getUserToken() + let publishableKey = await Knock.shared.environment.getPublishableKey() + let deviceToken = await Knock.shared.environment.getDeviceToken() - 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") + XCTAssertEqual(userId, nil) + XCTAssertEqual(userToken, nil) + XCTAssertEqual(publishableKey, "pk_123") + XCTAssertEqual(deviceToken, "test") } } diff --git a/Tests/KnockTests/ChannelTests.swift b/Tests/KnockTests/ChannelTests.swift index 6dc527b..c1555d9 100644 --- a/Tests/KnockTests/ChannelTests.swift +++ b/Tests/KnockTests/ChannelTests.swift @@ -11,11 +11,56 @@ import XCTest final class ChannelTests: XCTestCase { override func setUpWithError() throws { - try? Knock.shared.setup(publishableKey: "pk_123", pushChannelId: "test") + Task { + try? await Knock.shared.setup(publishableKey: "pk_123", pushChannelId: "test") + } } override func tearDownWithError() throws { - Knock.shared = Knock() + Knock.shared.resetInstanceCompletely() + } + + func testPrepareTokensWithNoChannelData() { + let newToken = "newToken" + let previousTokens = [newToken] + let tokens = Knock.shared.channelModule.getTokenDataForServer(newToken: newToken, previousTokens: previousTokens, channelDataTokens: [], forDeregistration: false) + XCTAssertEqual(tokens, [newToken]) + } + + func testPrepareTokensWithDuplicateToken() async { + let newToken = "newToken" + let previousTokens = [newToken] + let channelTokens = [newToken] + + let tokens = Knock.shared.channelModule.getTokenDataForServer(newToken: newToken, previousTokens: previousTokens, channelDataTokens: channelTokens, forDeregistration: false) + XCTAssertEqual(tokens, [newToken]) + } + + func testPrepareTokensWithOldTokensNeedingToBeRemoved() { + let newToken = "newToken" + let previousTokens = ["1234", newToken] + let channelTokens = ["1234", "12345"] + + let tokens = Knock.shared.channelModule.getTokenDataForServer(newToken: newToken, previousTokens: previousTokens, channelDataTokens: channelTokens, forDeregistration: false) + XCTAssertEqual(tokens, ["12345", newToken]) + } + + func testPrepareTokensWithFirstTimeToken() async { + let newToken = "newToken" + let previousTokens = ["1234", newToken, "1"] + let channelTokens = ["1234", "12345"] + + let tokens = Knock.shared.channelModule.getTokenDataForServer(newToken: newToken, previousTokens: previousTokens, channelDataTokens: channelTokens, forDeregistration: false) + XCTAssertEqual(tokens, ["12345", newToken]) + } + + func testPrepareTokensForDeregistration() async { + let newToken = "newToken" + let previousTokens = ["1234", newToken, "1"] + let channelTokens = ["1234", "12345", newToken] + + let tokens = Knock.shared.channelModule.getTokenDataForServer(newToken: newToken, previousTokens: previousTokens, channelDataTokens: channelTokens, forDeregistration: true) + XCTAssertEqual(tokens, ["12345"]) } } diff --git a/Tests/KnockTests/KnockTests.swift b/Tests/KnockTests/KnockTests.swift index b6bc977..c3854d5 100644 --- a/Tests/KnockTests/KnockTests.swift +++ b/Tests/KnockTests/KnockTests.swift @@ -10,16 +10,18 @@ import XCTest final class KnockTests: XCTestCase { override func setUpWithError() throws { - try? Knock.shared.setup(publishableKey: "pk_123", pushChannelId: "test") + Task { + try? await Knock.shared.setup(publishableKey: "pk_123", pushChannelId: "test") + } } override func tearDownWithError() throws { - Knock.shared = Knock() + Knock.shared.resetInstanceCompletely() } - func testPublishableKeyError() throws { + func testPublishableKeyError() async throws { do { - let _ = try Knock.shared.setup(publishableKey: "sk_123", pushChannelId: nil) + let _ = try await 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") @@ -30,7 +32,7 @@ final class KnockTests: XCTestCase { func testMakingNetworkRequestBeforeKnockSetUp() async { try! tearDownWithError() - Knock.shared.environment.setUserInfo(userId: "test", userToken: nil) + await Knock.shared.environment.setUserInfo(userId: "test", userToken: nil) do { let _ = try await Knock.shared.getUser() XCTFail("Expected function to throw an error, but it did not.") diff --git a/Tests/KnockTests/UserTests.swift b/Tests/KnockTests/UserTests.swift index 8c55a5c..9002623 100644 --- a/Tests/KnockTests/UserTests.swift +++ b/Tests/KnockTests/UserTests.swift @@ -11,15 +11,15 @@ import XCTest final class UserTests: XCTestCase { override func setUpWithError() throws { - try? Knock.shared.setup(publishableKey: "pk_123", pushChannelId: "test") + Task { + try? await 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.resetInstanceCompletely() } - - func testUserDecoding() throws { let decoder = JSONDecoder() From b1b7cd305d6d5d110cfa88cb73fae5998f0a5ccd Mon Sep 17 00:00:00 2001 From: Matt Gardner Date: Wed, 14 Feb 2024 12:57:02 -0700 Subject: [PATCH 04/10] Added migration guide --- Knock.docc/Knock.md | 104 ++++++++++++++++++++++++++++++--- MIGRATIONS.md | 29 +++++++++ README.md | 4 ++ Sources/KnockAppDelegate.swift | 1 + 4 files changed, 131 insertions(+), 7 deletions(-) create mode 100644 MIGRATIONS.md diff --git a/Knock.docc/Knock.md b/Knock.docc/Knock.md index aeeeb40..364d791 100755 --- a/Knock.docc/Knock.md +++ b/Knock.docc/Knock.md @@ -1,13 +1,103 @@ -# ``Knock`` +# Offical Knock iOS SDK -Summary +[![GitHub Release](https://img.shields.io/github/v/release/knocklabs/knock-swift?style=flat)](https://github.com/knocklabs/knock-swift/releases/latest) +[![CocoaPods](https://img.shields.io/cocoapods/v/Knock.svg?style=flat)](https://cocoapods.org/) +[![Carthage compatible](https://img.shields.io/badge/Carthage-compatible-4BC51D.svg?style=flat)](https://github.com/Carthage/Carthage) +[![Swift Package Manager compatible](https://img.shields.io/badge/Swift%20Package%20Manager-compatible-4BC51D.svg?style=flat)](https://swift.org/package-manager/) -## Overview +![min swift version is 5.3](https://img.shields.io/badge/min%20Swift%20version-5.3-orange) +![min ios version is 16](https://img.shields.io/badge/min%20iOS%20version-16-blue) +[![GitHub license](https://img.shields.io/badge/license-MIT-lightgrey.svg?style=flat)](https://github.com/knocklabs/ios-example-app/blob/main/LICENSE) -Text -## Topics +~ -### Group +--- -- ``Symbol`` \ No newline at end of file +Knock is a flexible, reliable notifications infrastructure that's built to scale with you. Use our iOS SDK to engage users with in-app feeds, setup push notifications, and manage notification preferences. + +--- + +## Documentation + +See the [documentation](https://docs.knock.app/notification-feeds/bring-your-own-ui) for usage examples. + +## Installation + +### Swift Package Manager + +There are two ways to add this as a dependency using the Swift Package Manager: + +1. Using Xcode +2. Manually via `Package.swift` + +#### Using Xcode + +1. Open your Xcode project and select `File` -> `Add Packages...` + +Screenshot 2023-06-27 at 19 41 32 + +2. Search for `https://github.com/knocklabs/knock-swift.git` and then click `Add Package` +*Note: We recommend that you set the Dependency Rule to Up to Next Major Version. While we encourage you to keep your app up to date with the latest SDK, major versions can include breaking changes or new features that require your attention.* + +Screenshot 2023-06-27 at 19 42 09 + +#### Manually via `Package.swift` + +If you are managing dependencies using the `Package.swift` file, just add this to you dependencies array: + +``` swift +dependencies: [ + .package(url: "https://github.com/knocklabs/knock-swift.git", .upToNextMajor(from: "1.0.0")) +] +``` + +### Cocoapods + +Add the dependency to your `Podfile`: + +``` +platform :ios, '16.0' +use_frameworks! + +target 'MyApp' do + pod 'Knock', '~> 0.2.0' +end +``` + +### Carthage + +1. Add this line to your Cartfile: + +``` +github "knocklabs/knock-swift" ~> 0.2.0 +``` + +### Manually + +As a last option, you could manually copy the files inside the `Sources` folder to your project. + +## Import and start using the SDK + +You can now start using the SDK: + +``` swift +import Knock + +// Setup the shared Knock instance as soon as you can. +try? Knock.shared.setup(publishableKey: "your-pk", pushChannelId: "user-id") + +// Once you know the Knock UserId, sign the user into the shared Knock instance. +await Knock.shared.signIn(userId: "userid", userToken: nil) + +``` + +## How to Contribute + +Community contributions are welcome! If you'd like to contribute, please read our [contribution guide](CONTRIBUTING.md). + +## License + +This project is licensed under the MIT license. + +See [LICENSE](LICENSE) for more information. diff --git a/MIGRATIONS.md b/MIGRATIONS.md new file mode 100644 index 0000000..ea372cd --- /dev/null +++ b/MIGRATIONS.md @@ -0,0 +1,29 @@ +# Migration Guide + +## Upgrading to Version 1.0.0 + +Version 1.0.0 of our Swift SDK introduces significant improvements and modernizations, including the adoption of Async/Await patterns for more concise and readable asynchronous code. While maintaining backward compatibility with completion handlers for all our APIs, we've also introduced several enhancements to optimize and streamline the SDK's usability. + +### Key Enhancements: + +- **Refined Initialization Process**: We've redesigned the initialization process for the Knock instance, dividing it into two distinct phases. This change offers greater flexibility in integrating our SDK into your projects. + +#### Previous Initialization Approach: +```swift +let client = try! Knock(publishableKey: publishableKey, "your-pk": "user-id", hostname: "hostname") +``` + +#### New in Version 1.0.0: +```swift +// Step 1: Early initialization. Ideal place: AppDelegate. +try? Knock.shared.setup(publishableKey: "your-pk", pushChannelId: "apns-channel-id", options: nil) + +// Step 2: Sign in the user. Ideal timing: as soon as you have the userId. +await Knock.shared.signIn(userId: "userid", userToken: nil) +``` + +- **KnockAppDelegate for Simplified Notification Management**: The introduction of `KnockAppDelegate` allows for effortless integration of push notification handling and token management, reducing boilerplate code and simplifying implementation. + +- **Enhanced User Session Management**: New functionalities to sign users out and unregister device tokens have been added, providing more control over user sessions and device management. + +- **Centralized Access with Shared Instance**: The SDK now utilizes a shared instance for the Knock client, facilitating easier access and interaction within your app's codebase. diff --git a/README.md b/README.md index b7d030e..836f974 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,10 @@ Knock is a flexible, reliable notifications infrastructure that's built to scale See the [documentation](https://docs.knock.app/notification-feeds/bring-your-own-ui) for usage examples. +## Migrations + +See the [Migration Guide](https://github.com/knocklabs/knock-swift/blob/main/MIGRATIONS.md) if upgrading from a previous version. + ## Installation ### Swift Package Manager diff --git a/Sources/KnockAppDelegate.swift b/Sources/KnockAppDelegate.swift index 2bd7f0d..872d079 100644 --- a/Sources/KnockAppDelegate.swift +++ b/Sources/KnockAppDelegate.swift @@ -33,6 +33,7 @@ open class KnockAppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificat /// - NOTE: If overriding this function in your AppDelegate, make sure to call super on this to get the default functionality as well. open func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool { UNUserNotificationCenter.current().delegate = self + // Will request push notification permissions, and will automatically register if perms are granted. Knock.shared.requestAndRegisterForPushNotifications() // Check if launched from the tap of a notification From fa16985b15b49fd514ab03319de1ae19dde95b33 Mon Sep 17 00:00:00 2001 From: Matt Gardner Date: Fri, 23 Feb 2024 09:25:23 -0700 Subject: [PATCH 05/10] Small fixes --- Sources/Modules/Feed/FeedModule.swift | 10 ++++++---- Sources/Modules/Feed/Models/Feed.swift | 4 ++-- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/Sources/Modules/Feed/FeedModule.swift b/Sources/Modules/Feed/FeedModule.swift index 00dc1dd..8d2d183 100644 --- a/Sources/Modules/Feed/FeedModule.swift +++ b/Sources/Modules/Feed/FeedModule.swift @@ -73,13 +73,15 @@ 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 mergedOptions = feedOptions.mergeOptions(options: options) + let userId = try await Knock.shared.environment.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!] : "" + "engagement_status": mergedOptions.status != nil && mergedOptions.status != .all ? mergedOptions.status!.rawValue : "", + "archived": mergedOptions.archived ?? "", + "has_tenant": mergedOptions.has_tenant ?? "", + "tenants": (mergedOptions.tenant != nil) ? [mergedOptions.tenant!] : "" ] do { let op = try await feedService.makeBulkStatusUpdate(feedId: feedId, type: type, body: body) diff --git a/Sources/Modules/Feed/Models/Feed.swift b/Sources/Modules/Feed/Models/Feed.swift index eab2db5..bf6f597 100644 --- a/Sources/Modules/Feed/Models/Feed.swift +++ b/Sources/Modules/Feed/Models/Feed.swift @@ -42,7 +42,7 @@ public extension Knock { // public let link_clicked_at: Date? public var read_at: Date? public var seen_at: Date? - public let tenant: String + public let tenant: String? public let total_activities: Int public let total_actors: Int public let updated_at: Date? @@ -56,7 +56,7 @@ public extension Knock { self.inserted_at = try container.decodeIfPresent(Date.self, forKey: Knock.FeedItem.CodingKeys.inserted_at) self.read_at = try container.decodeIfPresent(Date.self, forKey: Knock.FeedItem.CodingKeys.read_at) self.seen_at = try container.decodeIfPresent(Date.self, forKey: Knock.FeedItem.CodingKeys.seen_at) - self.tenant = try container.decode(String.self, forKey: Knock.FeedItem.CodingKeys.tenant) + self.tenant = try container.decodeIfPresent(String.self, forKey: Knock.FeedItem.CodingKeys.tenant) self.total_activities = try container.decode(Int.self, forKey: Knock.FeedItem.CodingKeys.total_activities) self.total_actors = try container.decode(Int.self, forKey: Knock.FeedItem.CodingKeys.total_actors) self.updated_at = try container.decodeIfPresent(Date.self, forKey: Knock.FeedItem.CodingKeys.updated_at) From bcc41c69c5828dcb24c47d8b1dfc0512bd8985ec Mon Sep 17 00:00:00 2001 From: Matt Gardner Date: Tue, 27 Feb 2024 14:23:05 -0700 Subject: [PATCH 06/10] Added additional props to FeedItem --- Sources/Modules/Feed/Models/Feed.swift | 54 +------------ Sources/Modules/Feed/Models/FeedItem.swift | 75 +++++++++++++++++++ .../Feed/Models/FeedItemActivity.swift | 30 ++++++++ 3 files changed, 106 insertions(+), 53 deletions(-) create mode 100644 Sources/Modules/Feed/Models/FeedItem.swift create mode 100644 Sources/Modules/Feed/Models/FeedItemActivity.swift diff --git a/Sources/Modules/Feed/Models/Feed.swift b/Sources/Modules/Feed/Models/Feed.swift index bf6f597..f0c6f84 100644 --- a/Sources/Modules/Feed/Models/Feed.swift +++ b/Sources/Modules/Feed/Models/Feed.swift @@ -11,7 +11,7 @@ public extension Knock { // https://docs.knock.app/reference#get-feed#feeds struct Feed: Codable { - public var entries: [FeedItem] = [] + public var entries: [Knock.FeedItem] = [] public var meta: FeedMetadata = FeedMetadata() public var page_info: PageInfo = PageInfo() @@ -30,56 +30,4 @@ public extension Knock { public init() {} } - - struct FeedItem: Codable { - public let __cursor: String - // public let clicked_at: Date? - public let blocks: [Block] - public let data: [String: AnyCodable]? // GenericData - public let id: String - public let inserted_at: Date? - // public let interacted_at: Date? - // public let link_clicked_at: Date? - public var read_at: Date? - public var seen_at: Date? - public let tenant: String? - public let total_activities: Int - public let total_actors: Int - public let updated_at: Date? - - public init(from decoder: Decoder) throws { - let container: KeyedDecodingContainer = try decoder.container(keyedBy: Knock.FeedItem.CodingKeys.self) - self.__cursor = try container.decode(String.self, forKey: Knock.FeedItem.CodingKeys.__cursor) - self.blocks = try container.decode([Knock.Block].self, forKey: Knock.FeedItem.CodingKeys.blocks) - self.data = try container.decodeIfPresent([String : AnyCodable].self, forKey: Knock.FeedItem.CodingKeys.data) - self.id = try container.decode(String.self, forKey: Knock.FeedItem.CodingKeys.id) - self.inserted_at = try container.decodeIfPresent(Date.self, forKey: Knock.FeedItem.CodingKeys.inserted_at) - self.read_at = try container.decodeIfPresent(Date.self, forKey: Knock.FeedItem.CodingKeys.read_at) - self.seen_at = try container.decodeIfPresent(Date.self, forKey: Knock.FeedItem.CodingKeys.seen_at) - self.tenant = try container.decodeIfPresent(String.self, forKey: Knock.FeedItem.CodingKeys.tenant) - self.total_activities = try container.decode(Int.self, forKey: Knock.FeedItem.CodingKeys.total_activities) - self.total_actors = try container.decode(Int.self, forKey: Knock.FeedItem.CodingKeys.total_actors) - self.updated_at = try container.decodeIfPresent(Date.self, forKey: Knock.FeedItem.CodingKeys.updated_at) - } - - public init(__cursor: String, blocks: [Block], data: [String : AnyCodable]?, id: String, inserted_at: Date?, read_at: Date? = nil, seen_at: Date? = nil, tenant: String, total_activities: Int, total_actors: Int, updated_at: Date?) { - self.__cursor = __cursor - self.blocks = blocks - self.data = data - self.id = id - self.inserted_at = inserted_at - self.read_at = read_at - self.seen_at = seen_at - self.tenant = tenant - self.total_activities = total_activities - self.total_actors = total_actors - self.updated_at = updated_at - } - } - - struct FeedMetadata: Codable { - public var total_count: Int = 0 - public var unread_count: Int = 0 - public var unseen_count: Int = 0 - } } diff --git a/Sources/Modules/Feed/Models/FeedItem.swift b/Sources/Modules/Feed/Models/FeedItem.swift new file mode 100644 index 0000000..eb5c754 --- /dev/null +++ b/Sources/Modules/Feed/Models/FeedItem.swift @@ -0,0 +1,75 @@ +// +// FeedItem.swift +// +// +// Created by Matt Gardner on 2/27/24. +// + +import Foundation + +public extension Knock { + + struct FeedItem: Codable { + public let __cursor: String + public let actors: [User]? + public let activities: [FeedItemActivity]? + public let blocks: [Block] + public let data: [String: AnyCodable]? // GenericData + public let id: String + public let inserted_at: Date? + public let interacted_at: Date? + public let clicked_at: Date? + public let link_clicked_at: Date? + public var read_at: Date? + public var seen_at: Date? + public let tenant: String? + public let total_activities: Int + public let total_actors: Int + public let updated_at: Date? + + public init(from decoder: Decoder) throws { + let container: KeyedDecodingContainer = try decoder.container(keyedBy: Knock.FeedItem.CodingKeys.self) + self.__cursor = try container.decode(String.self, forKey: Knock.FeedItem.CodingKeys.__cursor) + self.actors = try container.decodeIfPresent([User].self, forKey: Knock.FeedItem.CodingKeys.actors) + self.activities = try container.decodeIfPresent([FeedItemActivity].self, forKey: Knock.FeedItem.CodingKeys.activities) + self.blocks = try container.decode([Knock.Block].self, forKey: Knock.FeedItem.CodingKeys.blocks) + self.data = try container.decodeIfPresent([String : AnyCodable].self, forKey: Knock.FeedItem.CodingKeys.data) + self.id = try container.decode(String.self, forKey: Knock.FeedItem.CodingKeys.id) + self.inserted_at = try container.decodeIfPresent(Date.self, forKey: Knock.FeedItem.CodingKeys.inserted_at) + self.interacted_at = try container.decodeIfPresent(Date.self, forKey: Knock.FeedItem.CodingKeys.interacted_at) + self.clicked_at = try container.decodeIfPresent(Date.self, forKey: Knock.FeedItem.CodingKeys.clicked_at) + self.link_clicked_at = try container.decodeIfPresent(Date.self, forKey: Knock.FeedItem.CodingKeys.link_clicked_at) + self.read_at = try container.decodeIfPresent(Date.self, forKey: Knock.FeedItem.CodingKeys.read_at) + self.seen_at = try container.decodeIfPresent(Date.self, forKey: Knock.FeedItem.CodingKeys.seen_at) + self.tenant = try container.decodeIfPresent(String.self, forKey: Knock.FeedItem.CodingKeys.tenant) + self.total_activities = try container.decode(Int.self, forKey: Knock.FeedItem.CodingKeys.total_activities) + self.total_actors = try container.decode(Int.self, forKey: Knock.FeedItem.CodingKeys.total_actors) + self.updated_at = try container.decodeIfPresent(Date.self, forKey: Knock.FeedItem.CodingKeys.updated_at) + } + + public init(__cursor: String, actors: [User]?, activities: [FeedItemActivity]?, blocks: [Block], data: [String : AnyCodable]?, id: String, inserted_at: Date?, interacted_at: Date?, clicked_at: Date?, link_clicked_at: Date?, read_at: Date? = nil, seen_at: Date? = nil, tenant: String, total_activities: Int, total_actors: Int, updated_at: Date?) { + self.__cursor = __cursor + self.blocks = blocks + self.actors = actors + self.activities = activities + self.data = data + self.id = id + self.inserted_at = inserted_at + self.read_at = read_at + self.seen_at = seen_at + self.tenant = tenant + self.total_activities = total_activities + self.total_actors = total_actors + self.updated_at = updated_at + self.interacted_at = interacted_at + self.clicked_at = clicked_at + self.link_clicked_at = link_clicked_at + } + } + + struct FeedMetadata: Codable { + public var total_count: Int = 0 + public var unread_count: Int = 0 + public var unseen_count: Int = 0 + } +} diff --git a/Sources/Modules/Feed/Models/FeedItemActivity.swift b/Sources/Modules/Feed/Models/FeedItemActivity.swift new file mode 100644 index 0000000..20662fb --- /dev/null +++ b/Sources/Modules/Feed/Models/FeedItemActivity.swift @@ -0,0 +1,30 @@ +// +// FeedItemActivity.swift +// +// +// Created by Matt Gardner on 2/27/24. +// + +import Foundation + +public extension Knock { + + struct FeedItemActivity: Codable { + public let actor: User? + public let recipient: User? + public let data: [String: AnyCodable]? // GenericData + public let id: String + public let inserted_at: Date? + public let updated_at: Date? + + public init(from decoder: Decoder) throws { + let container: KeyedDecodingContainer = try decoder.container(keyedBy: Knock.FeedItemActivity.CodingKeys.self) + self.id = try container.decode(String.self, forKey: Knock.FeedItemActivity.CodingKeys.id) + self.actor = try container.decodeIfPresent(User.self, forKey: Knock.FeedItemActivity.CodingKeys.actor) + self.recipient = try container.decodeIfPresent(User.self, forKey: Knock.FeedItemActivity.CodingKeys.recipient) + self.data = try container.decodeIfPresent([String : AnyCodable].self, forKey: Knock.FeedItemActivity.CodingKeys.data) + self.inserted_at = try container.decodeIfPresent(Date.self, forKey: Knock.FeedItemActivity.CodingKeys.inserted_at) + self.updated_at = try container.decodeIfPresent(Date.self, forKey: Knock.FeedItemActivity.CodingKeys.updated_at) + } + } +} From 801eb8aa89879aa23bdc75207d9869c0497e8d5d Mon Sep 17 00:00:00 2001 From: Matt Gardner Date: Thu, 7 Mar 2024 19:05:40 -0700 Subject: [PATCH 07/10] Cleanup --- Sources/{Modules/Feed => }/FeedManager.swift | 0 Sources/KnockAppDelegate.swift | 2 -- Sources/{Modules/Channels => Models}/ChannelData.swift | 2 +- Sources/{Modules/Feed/Models => Models/Feed}/Block.swift | 0 .../{Modules/Feed/Models => Models/Feed}/BulkOperation.swift | 0 Sources/{Modules/Feed/Models => Models/Feed}/Feed.swift | 0 .../Feed/Models => Models/Feed}/FeedClientOptions.swift | 0 Sources/{Modules/Feed/Models => Models/Feed}/FeedItem.swift | 0 .../{Modules/Feed/Models => Models/Feed}/FeedItemActivity.swift | 0 .../{Modules/Feed/Models => Models/Feed}/FeedItemScope.swift | 0 .../Feed/Models => Models/Feed}/NotificationSource.swift | 0 Sources/{Modules/Feed/Models => Models/Feed}/PageInfo.swift | 0 .../Messages/Models => Models/Messages}/KnockMessage.swift | 0 .../Models => Models/Messages}/KnockMessageStatus.swift | 0 .../Models => Models/Preferences}/ChannelTypePreferences.swift | 0 .../Preferences/Models => Models/Preferences}/Condition.swift | 0 .../Models => Models/Preferences}/PreferenceSet.swift | 0 .../Models => Models/Preferences}/WorkflowPreference.swift | 0 Sources/{Modules/Users => Models}/User.swift | 0 Sources/Modules/{Authentication => }/AuthenticationModule.swift | 0 Sources/Modules/{Channels => }/ChannelModule.swift | 0 Sources/Modules/{Feed => }/FeedModule.swift | 0 Sources/Modules/{Messages => }/MessageModule.swift | 0 Sources/Modules/{Preferences => }/PreferenceModule.swift | 0 Sources/Modules/{Users => }/UserModule.swift | 0 Sources/{Modules/Channels => Services}/ChannelService.swift | 0 Sources/{Modules/Feed => Services}/FeedService.swift | 0 Sources/{Modules/Messages => Services}/MessageService.swift | 0 .../{Modules/Preferences => Services}/PreferenceService.swift | 0 Sources/{Modules/Users => Services}/UserService.swift | 0 30 files changed, 1 insertion(+), 3 deletions(-) rename Sources/{Modules/Feed => }/FeedManager.swift (100%) rename Sources/{Modules/Channels => Models}/ChannelData.swift (76%) rename Sources/{Modules/Feed/Models => Models/Feed}/Block.swift (100%) rename Sources/{Modules/Feed/Models => Models/Feed}/BulkOperation.swift (100%) rename Sources/{Modules/Feed/Models => Models/Feed}/Feed.swift (100%) rename Sources/{Modules/Feed/Models => Models/Feed}/FeedClientOptions.swift (100%) rename Sources/{Modules/Feed/Models => Models/Feed}/FeedItem.swift (100%) rename Sources/{Modules/Feed/Models => Models/Feed}/FeedItemActivity.swift (100%) rename Sources/{Modules/Feed/Models => Models/Feed}/FeedItemScope.swift (100%) rename Sources/{Modules/Feed/Models => Models/Feed}/NotificationSource.swift (100%) rename Sources/{Modules/Feed/Models => Models/Feed}/PageInfo.swift (100%) rename Sources/{Modules/Messages/Models => Models/Messages}/KnockMessage.swift (100%) rename Sources/{Modules/Messages/Models => Models/Messages}/KnockMessageStatus.swift (100%) rename Sources/{Modules/Preferences/Models => Models/Preferences}/ChannelTypePreferences.swift (100%) rename Sources/{Modules/Preferences/Models => Models/Preferences}/Condition.swift (100%) rename Sources/{Modules/Preferences/Models => Models/Preferences}/PreferenceSet.swift (100%) rename Sources/{Modules/Preferences/Models => Models/Preferences}/WorkflowPreference.swift (100%) rename Sources/{Modules/Users => Models}/User.swift (100%) rename Sources/Modules/{Authentication => }/AuthenticationModule.swift (100%) rename Sources/Modules/{Channels => }/ChannelModule.swift (100%) rename Sources/Modules/{Feed => }/FeedModule.swift (100%) rename Sources/Modules/{Messages => }/MessageModule.swift (100%) rename Sources/Modules/{Preferences => }/PreferenceModule.swift (100%) rename Sources/Modules/{Users => }/UserModule.swift (100%) rename Sources/{Modules/Channels => Services}/ChannelService.swift (100%) rename Sources/{Modules/Feed => Services}/FeedService.swift (100%) rename Sources/{Modules/Messages => Services}/MessageService.swift (100%) rename Sources/{Modules/Preferences => Services}/PreferenceService.swift (100%) rename Sources/{Modules/Users => Services}/UserService.swift (100%) diff --git a/Sources/Modules/Feed/FeedManager.swift b/Sources/FeedManager.swift similarity index 100% rename from Sources/Modules/Feed/FeedManager.swift rename to Sources/FeedManager.swift diff --git a/Sources/KnockAppDelegate.swift b/Sources/KnockAppDelegate.swift index 872d079..ed31bae 100644 --- a/Sources/KnockAppDelegate.swift +++ b/Sources/KnockAppDelegate.swift @@ -92,8 +92,6 @@ open class KnockAppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificat public func getMessageId(userInfo: [AnyHashable : Any]) -> String? { return userInfo["knock_message_id"] as? String } - - open func deviceTokenDidChange(apnsToken: String) {} open func pushNotificationDeliveredInForeground(notification: UNNotification) -> UNNotificationPresentationOptions { if let messageId = getMessageId(userInfo: notification.request.content.userInfo) { diff --git a/Sources/Modules/Channels/ChannelData.swift b/Sources/Models/ChannelData.swift similarity index 76% rename from Sources/Modules/Channels/ChannelData.swift rename to Sources/Models/ChannelData.swift index 3e854ec..6cd3d71 100644 --- a/Sources/Modules/Channels/ChannelData.swift +++ b/Sources/Models/ChannelData.swift @@ -10,6 +10,6 @@ import Foundation public extension Knock { struct ChannelData: Codable { public let channel_id: String - public let data: [String: AnyCodable]? // GenericData + public let data: [String: AnyCodable]? } } diff --git a/Sources/Modules/Feed/Models/Block.swift b/Sources/Models/Feed/Block.swift similarity index 100% rename from Sources/Modules/Feed/Models/Block.swift rename to Sources/Models/Feed/Block.swift diff --git a/Sources/Modules/Feed/Models/BulkOperation.swift b/Sources/Models/Feed/BulkOperation.swift similarity index 100% rename from Sources/Modules/Feed/Models/BulkOperation.swift rename to Sources/Models/Feed/BulkOperation.swift diff --git a/Sources/Modules/Feed/Models/Feed.swift b/Sources/Models/Feed/Feed.swift similarity index 100% rename from Sources/Modules/Feed/Models/Feed.swift rename to Sources/Models/Feed/Feed.swift diff --git a/Sources/Modules/Feed/Models/FeedClientOptions.swift b/Sources/Models/Feed/FeedClientOptions.swift similarity index 100% rename from Sources/Modules/Feed/Models/FeedClientOptions.swift rename to Sources/Models/Feed/FeedClientOptions.swift diff --git a/Sources/Modules/Feed/Models/FeedItem.swift b/Sources/Models/Feed/FeedItem.swift similarity index 100% rename from Sources/Modules/Feed/Models/FeedItem.swift rename to Sources/Models/Feed/FeedItem.swift diff --git a/Sources/Modules/Feed/Models/FeedItemActivity.swift b/Sources/Models/Feed/FeedItemActivity.swift similarity index 100% rename from Sources/Modules/Feed/Models/FeedItemActivity.swift rename to Sources/Models/Feed/FeedItemActivity.swift diff --git a/Sources/Modules/Feed/Models/FeedItemScope.swift b/Sources/Models/Feed/FeedItemScope.swift similarity index 100% rename from Sources/Modules/Feed/Models/FeedItemScope.swift rename to Sources/Models/Feed/FeedItemScope.swift diff --git a/Sources/Modules/Feed/Models/NotificationSource.swift b/Sources/Models/Feed/NotificationSource.swift similarity index 100% rename from Sources/Modules/Feed/Models/NotificationSource.swift rename to Sources/Models/Feed/NotificationSource.swift diff --git a/Sources/Modules/Feed/Models/PageInfo.swift b/Sources/Models/Feed/PageInfo.swift similarity index 100% rename from Sources/Modules/Feed/Models/PageInfo.swift rename to Sources/Models/Feed/PageInfo.swift diff --git a/Sources/Modules/Messages/Models/KnockMessage.swift b/Sources/Models/Messages/KnockMessage.swift similarity index 100% rename from Sources/Modules/Messages/Models/KnockMessage.swift rename to Sources/Models/Messages/KnockMessage.swift diff --git a/Sources/Modules/Messages/Models/KnockMessageStatus.swift b/Sources/Models/Messages/KnockMessageStatus.swift similarity index 100% rename from Sources/Modules/Messages/Models/KnockMessageStatus.swift rename to Sources/Models/Messages/KnockMessageStatus.swift diff --git a/Sources/Modules/Preferences/Models/ChannelTypePreferences.swift b/Sources/Models/Preferences/ChannelTypePreferences.swift similarity index 100% rename from Sources/Modules/Preferences/Models/ChannelTypePreferences.swift rename to Sources/Models/Preferences/ChannelTypePreferences.swift diff --git a/Sources/Modules/Preferences/Models/Condition.swift b/Sources/Models/Preferences/Condition.swift similarity index 100% rename from Sources/Modules/Preferences/Models/Condition.swift rename to Sources/Models/Preferences/Condition.swift diff --git a/Sources/Modules/Preferences/Models/PreferenceSet.swift b/Sources/Models/Preferences/PreferenceSet.swift similarity index 100% rename from Sources/Modules/Preferences/Models/PreferenceSet.swift rename to Sources/Models/Preferences/PreferenceSet.swift diff --git a/Sources/Modules/Preferences/Models/WorkflowPreference.swift b/Sources/Models/Preferences/WorkflowPreference.swift similarity index 100% rename from Sources/Modules/Preferences/Models/WorkflowPreference.swift rename to Sources/Models/Preferences/WorkflowPreference.swift diff --git a/Sources/Modules/Users/User.swift b/Sources/Models/User.swift similarity index 100% rename from Sources/Modules/Users/User.swift rename to Sources/Models/User.swift diff --git a/Sources/Modules/Authentication/AuthenticationModule.swift b/Sources/Modules/AuthenticationModule.swift similarity index 100% rename from Sources/Modules/Authentication/AuthenticationModule.swift rename to Sources/Modules/AuthenticationModule.swift diff --git a/Sources/Modules/Channels/ChannelModule.swift b/Sources/Modules/ChannelModule.swift similarity index 100% rename from Sources/Modules/Channels/ChannelModule.swift rename to Sources/Modules/ChannelModule.swift diff --git a/Sources/Modules/Feed/FeedModule.swift b/Sources/Modules/FeedModule.swift similarity index 100% rename from Sources/Modules/Feed/FeedModule.swift rename to Sources/Modules/FeedModule.swift diff --git a/Sources/Modules/Messages/MessageModule.swift b/Sources/Modules/MessageModule.swift similarity index 100% rename from Sources/Modules/Messages/MessageModule.swift rename to Sources/Modules/MessageModule.swift diff --git a/Sources/Modules/Preferences/PreferenceModule.swift b/Sources/Modules/PreferenceModule.swift similarity index 100% rename from Sources/Modules/Preferences/PreferenceModule.swift rename to Sources/Modules/PreferenceModule.swift diff --git a/Sources/Modules/Users/UserModule.swift b/Sources/Modules/UserModule.swift similarity index 100% rename from Sources/Modules/Users/UserModule.swift rename to Sources/Modules/UserModule.swift diff --git a/Sources/Modules/Channels/ChannelService.swift b/Sources/Services/ChannelService.swift similarity index 100% rename from Sources/Modules/Channels/ChannelService.swift rename to Sources/Services/ChannelService.swift diff --git a/Sources/Modules/Feed/FeedService.swift b/Sources/Services/FeedService.swift similarity index 100% rename from Sources/Modules/Feed/FeedService.swift rename to Sources/Services/FeedService.swift diff --git a/Sources/Modules/Messages/MessageService.swift b/Sources/Services/MessageService.swift similarity index 100% rename from Sources/Modules/Messages/MessageService.swift rename to Sources/Services/MessageService.swift diff --git a/Sources/Modules/Preferences/PreferenceService.swift b/Sources/Services/PreferenceService.swift similarity index 100% rename from Sources/Modules/Preferences/PreferenceService.swift rename to Sources/Services/PreferenceService.swift diff --git a/Sources/Modules/Users/UserService.swift b/Sources/Services/UserService.swift similarity index 100% rename from Sources/Modules/Users/UserService.swift rename to Sources/Services/UserService.swift From 874aa9d9919ba4d80a227d34fc3c587197216492 Mon Sep 17 00:00:00 2001 From: Matt Gardner Date: Tue, 12 Mar 2024 13:41:32 -0600 Subject: [PATCH 08/10] final cleanup --- README.md | 6 +++++- Sources/Knock.swift | 3 ++- Sources/KnockEnvironment.swift | 17 ----------------- Sources/Modules/ChannelModule.swift | 2 +- Sources/Modules/PreferenceModule.swift | 6 +++--- Sources/Modules/UserModule.swift | 2 +- 6 files changed, 12 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 836f974..685daa5 100644 --- a/README.md +++ b/README.md @@ -20,12 +20,16 @@ Knock is a flexible, reliable notifications infrastructure that's built to scale ## Documentation -See the [documentation](https://docs.knock.app/notification-feeds/bring-your-own-ui) for usage examples. +See the [documentation](https://docs.knock.app/sdks/ios/overview) for full documentation. ## Migrations See the [Migration Guide](https://github.com/knocklabs/knock-swift/blob/main/MIGRATIONS.md) if upgrading from a previous version. +## Example App + +See the [iOS Example App](https://github.com/knocklabs/ios-example-app) for more examples. + ## Installation ### Swift Package Manager diff --git a/Sources/Knock.swift b/Sources/Knock.swift index a656be9..4c16fc1 100644 --- a/Sources/Knock.swift +++ b/Sources/Knock.swift @@ -25,10 +25,11 @@ public class Knock { internal lazy var logger = KnockLogger() /** - Returns a new instance of the Knock Client + Sets up the shared Knock instance. Make sure to call this as soon as you can. Preferrably in your AppDelegate. - Parameters: - publishableKey: Your public API key + - pushChannelId: - options: [optional] Options for customizing the Knock instance. */ public func setup(publishableKey: String, pushChannelId: String?, options: Knock.KnockStartupOptions? = nil) async throws { diff --git a/Sources/KnockEnvironment.swift b/Sources/KnockEnvironment.swift index 237d849..fac7733 100644 --- a/Sources/KnockEnvironment.swift +++ b/Sources/KnockEnvironment.swift @@ -97,23 +97,6 @@ internal actor KnockEnvironment { } // APNS Device Token - -// public func setDeviceToken(_ token: String?) async { -// let currentToken = getDeviceToken() -// if currentToken != token { -// var previousTokens = await getPreviousPushTokens() -// var tokenSet: Set = Set(previousTokens) -// if let currentToken = currentToken { -// tokenSet.insert(currentToken) -// } -// if let token = token { -// tokenSet.insert(token) -// } -// setPreviousPushTokens(tokens: Array(tokenSet)) -// defaults.set(token, forKey: userDevicePushTokenKey) -// } -// } - public func setDeviceToken(_ token: String?) async { let previousTokens = getPreviousPushTokens() if let token = token, !previousTokens.contains(token) { diff --git a/Sources/Modules/ChannelModule.swift b/Sources/Modules/ChannelModule.swift index 7f3694b..bffc824 100644 --- a/Sources/Modules/ChannelModule.swift +++ b/Sources/Modules/ChannelModule.swift @@ -176,7 +176,7 @@ public extension Knock { /** Retrieves the channel data for the current user on the channel specified. - https://docs.knock.app/reference#get-user-channel-data#get-user-channel-data + https://docs.knock.app/reference#get-user-channel-data - Parameters: - channelId: The id of the Knock channel to lookup. diff --git a/Sources/Modules/PreferenceModule.swift b/Sources/Modules/PreferenceModule.swift index b093ed2..d53f2db 100644 --- a/Sources/Modules/PreferenceModule.swift +++ b/Sources/Modules/PreferenceModule.swift @@ -49,7 +49,7 @@ public extension Knock { /** Retrieve all user's preference sets. Will always return an empty preference set object, even if it does not currently exist for the user. - https://docs.knock.app/reference#get-preferences-user#get-preferences-user + https://docs.knock.app/reference#get-preferences-user */ func getAllUserPreferences() async throws -> [Knock.PreferenceSet] { @@ -69,7 +69,7 @@ public extension Knock { /** Retrieve a user's preference set. Will always return an empty preference set object, even if it does not currently exist for the user. - https://docs.knock.app/reference#get-preferences-user#get-preferences-user + https://docs.knock.app/reference#get-preferences-user - Parameters: - preferenceId: The preferenceId for the PreferenceSet. @@ -96,7 +96,7 @@ public extension Knock { The preference set :id can be either "default" or a tenant.id. Learn more about per-tenant preference sets in our preferences guide. https://docs.knock.app/send-and-manage-data/preferences#preference-sets - https://docs.knock.app/reference#get-preferences-user#set-preferences-user + https://docs.knock.app/reference#get-preferences-user - Parameters: - preferenceId: The preferenceId for the PreferenceSet. diff --git a/Sources/Modules/UserModule.swift b/Sources/Modules/UserModule.swift index 0811417..a08a634 100644 --- a/Sources/Modules/UserModule.swift +++ b/Sources/Modules/UserModule.swift @@ -49,7 +49,7 @@ public extension Knock { /** Retrieve the current user, including all properties previously set. - https://docs.knock.app/reference#get-user#get-user + https://docs.knock.app/reference#get-user */ func getUser() async throws -> User { return try await userModule.getUser() From bd95f10347ef502529067233ea4e34768346f553 Mon Sep 17 00:00:00 2001 From: Matt Gardner Date: Tue, 12 Mar 2024 14:39:12 -0600 Subject: [PATCH 09/10] spelling fix --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 685daa5..f6a42f9 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ -# Offical Knock iOS SDK +# Official Knock iOS SDK [![GitHub Release](https://img.shields.io/github/v/release/knocklabs/knock-swift?style=flat)](https://github.com/knocklabs/knock-swift/releases/latest) [![CocoaPods](https://img.shields.io/cocoapods/v/Knock.svg?style=flat)](https://cocoapods.org/) From 944fba66a4f09877c36c0ca9069a19a510f254c7 Mon Sep 17 00:00:00 2001 From: Matt Gardner Date: Thu, 14 Mar 2024 11:24:29 -0600 Subject: [PATCH 10/10] Cleanup based on PR feedback --- README.md | 8 ++++++-- Sources/FeedManager.swift | 4 ++-- Sources/Knock.swift | 4 ++-- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index f6a42f9..bea3c4a 100644 --- a/README.md +++ b/README.md @@ -92,8 +92,12 @@ You can now start using the SDK: ``` swift import Knock -// Setup the shared Knock instance as soon as you can. -try? Knock.shared.setup(publishableKey: "your-pk", pushChannelId: "user-id") +/* + Setup the shared Knock instance as soon as you can. + Note: pushChannelId is required if you want to use our KnockAppDelegate helper. + Otherwise, this field is optional. +*/ +try? Knock.shared.setup(publishableKey: "your-pk", pushChannelId: "apns-push-channel-id") // Once you know the Knock UserId, sign the user into the shared Knock instance. await Knock.shared.signIn(userId: "userid", userToken: nil) diff --git a/Sources/FeedManager.swift b/Sources/FeedManager.swift index 48c8214..3ccaf0d 100644 --- a/Sources/FeedManager.swift +++ b/Sources/FeedManager.swift @@ -30,7 +30,7 @@ public extension Knock { } deinit { - unregisterFromAppLifecycleNotifications() + deregisterFromAppLifecycleNotifications() } private func registerForAppLifecycleNotifications() { @@ -43,7 +43,7 @@ public extension Knock { } } - private func unregisterFromAppLifecycleNotifications() { + private func deregisterFromAppLifecycleNotifications() { if let observer = foregroundObserver { NotificationCenter.default.removeObserver(observer) } diff --git a/Sources/Knock.swift b/Sources/Knock.swift index 4c16fc1..e37badd 100644 --- a/Sources/Knock.swift +++ b/Sources/Knock.swift @@ -29,8 +29,8 @@ public class Knock { - Parameters: - publishableKey: Your public API key - - pushChannelId: - - options: [optional] Options for customizing the Knock instance. + - pushChannelId: [optional] The Knock APNS channel id that you plan to use within your app + - options: [optional] Options for customizing the Knock instance */ public func setup(publishableKey: String, pushChannelId: String?, options: Knock.KnockStartupOptions? = nil) async throws { logger.loggingDebugOptions = options?.loggingOptions ?? .errorsOnly