From a9778d0df6528223e1b67ee7817694ba2e0b5f53 Mon Sep 17 00:00:00 2001 From: Matt Gardner Date: Wed, 24 Jan 2024 15:32:15 -0700 Subject: [PATCH 1/9] Starting on initial refactor --- Sources/KnockAppDelegate.swift | 90 +++++++++++++++ .../Feed/Models/FeedClientOptions.swift | 104 ++++++++++++++++++ .../Modules/Feed/Models/FeedItemScope.swift | 40 +++++++ 3 files changed, 234 insertions(+) create mode 100644 Sources/KnockAppDelegate.swift create mode 100644 Sources/Modules/Feed/Models/FeedClientOptions.swift create mode 100644 Sources/Modules/Feed/Models/FeedItemScope.swift diff --git a/Sources/KnockAppDelegate.swift b/Sources/KnockAppDelegate.swift new file mode 100644 index 0000000..51c03a6 --- /dev/null +++ b/Sources/KnockAppDelegate.swift @@ -0,0 +1,90 @@ +// +// KnockAppDelegate.swift +// +// +// Created by Matt Gardner on 1/22/24. +// + +import Foundation +import UIKit +import OSLog + +@available(iOSApplicationExtension, unavailable) +open class KnockAppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterDelegate { + + private let logger = Logger(subsystem: "knock-swift", category: "KnockAppDelegate") + + // MARK: Init + + override init() { + super.init() + + // Register to ensure device token can be fetched + UIApplication.shared.registerForRemoteNotifications() + UNUserNotificationCenter.current().delegate = self + + } + + // MARK: Launching + + open func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool { + return true + } + + // MARK: Notifications + + open func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { + logger.debug("userNotificationCenter willPresent notification: \(notification)") + + let userInfo = notification.request.content.userInfo + + let presentationOptions = pushNotificationDeliveredInForeground(userInfo: userInfo) + completionHandler(presentationOptions) + } + + open func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { + logger.debug("didReceiveNotificationResponse: \(response)") + + let userInfo = response.notification.request.content.userInfo + + if response.actionIdentifier == UNNotificationDismissActionIdentifier { + pushNotificationDismissed(userInfo: userInfo) + } else { + pushNotificationTapped(userInfo: userInfo) + } + completionHandler() + } + + // MARK: Token Management + + open func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) { + logger.error("Failed to register for notifications: \(error.localizedDescription)") + } + + open func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { + logger.debug("Successfully registered for notifications!") + + // 1. Convert device token to string + let tokenParts = deviceToken.map { data -> String in + return String(format: "%02.2hhx", data) + } + let token = tokenParts.joined() + // 2. Print device token to use for PNs payloads + logger.debug("Device Token: \(token)") + + let defaults = UserDefaults.standard + defaults.set(token, forKey: "device_push_token") + deviceTokenDidChange(apnsToken: token, isDebugging: isDebuggerAttached) +// self.pushToken = token + } + + // MARK: Functions + + open func deviceTokenDidChange(apnsToken: String, isDebugging: Bool) {} + + open func pushNotificationDeliveredInForeground(userInfo: [AnyHashable : Any]) -> UNNotificationPresentationOptions { return [] } + + open func pushNotificationTapped(userInfo: [AnyHashable : Any]) {} + + open func pushNotificationDismissed(userInfo: [AnyHashable : Any]) {} +} diff --git a/Sources/Modules/Feed/Models/FeedClientOptions.swift b/Sources/Modules/Feed/Models/FeedClientOptions.swift new file mode 100644 index 0000000..353e9bd --- /dev/null +++ b/Sources/Modules/Feed/Models/FeedClientOptions.swift @@ -0,0 +1,104 @@ +// +// FeedClientOptions.swift +// +// +// Created by Matt Gardner on 1/23/24. +// + +import Foundation + +extension Knock.FeedManager { + public struct FeedClientOptions: Codable { + public var before: String? + public var after: String? + public var page_size: Int? + public var status: FeedItemScope? + public var source: String? // Optionally scope all notifications to a particular source only + public var tenant: String? // Optionally scope all requests to a particular tenant + public var has_tenant: Bool? // Optionally scope to notifications with any tenancy or no tenancy + public var archived: FeedItemArchivedScope? // Optionally scope to a given archived status (defaults to `exclude`) + public var trigger_data: [String: AnyCodable]? // GenericData + + public init(from decoder: Decoder) throws { + let container: KeyedDecodingContainer = try decoder.container(keyedBy: Knock.FeedManager.FeedClientOptions.CodingKeys.self) + self.before = try container.decodeIfPresent(String.self, forKey: Knock.FeedManager.FeedClientOptions.CodingKeys.before) + self.after = try container.decodeIfPresent(String.self, forKey: Knock.FeedManager.FeedClientOptions.CodingKeys.after) + self.page_size = try container.decodeIfPresent(Int.self, forKey: Knock.FeedManager.FeedClientOptions.CodingKeys.page_size) + self.status = try container.decodeIfPresent(Knock.FeedManager.FeedItemScope.self, forKey: Knock.FeedManager.FeedClientOptions.CodingKeys.status) + self.source = try container.decodeIfPresent(String.self, forKey: Knock.FeedManager.FeedClientOptions.CodingKeys.source) + self.tenant = try container.decodeIfPresent(String.self, forKey: Knock.FeedManager.FeedClientOptions.CodingKeys.tenant) + self.has_tenant = try container.decodeIfPresent(Bool.self, forKey: Knock.FeedManager.FeedClientOptions.CodingKeys.has_tenant) + self.archived = try container.decodeIfPresent(Knock.FeedManager.FeedItemArchivedScope.self, forKey: Knock.FeedManager.FeedClientOptions.CodingKeys.archived) + self.trigger_data = try container.decodeIfPresent([String : AnyCodable].self, forKey: Knock.FeedManager.FeedClientOptions.CodingKeys.trigger_data) + } + + public init(before: String? = nil, after: String? = nil, page_size: Int? = nil, status: FeedItemScope? = nil, source: String? = nil, tenant: String? = nil, has_tenant: Bool? = nil, archived: FeedItemArchivedScope? = nil, trigger_data: [String : AnyCodable]? = nil) { + self.before = before + self.after = after + self.page_size = page_size + self.status = status + self.source = source + self.tenant = tenant + self.has_tenant = has_tenant + self.archived = archived + self.trigger_data = trigger_data + } + + /** + Returns a new struct of type `FeedClientOptions` with the options passed as the parameter merged into it. + + - Parameters: + - options: the options to merge with the current struct, if they are nil, only a copy of `self` will be returned + */ + public func mergeOptions(options: FeedClientOptions? = nil) -> FeedClientOptions { + // initialize a new `mergedOptions` struct with all the properties of the `self` struct + var mergedOptions = FeedClientOptions( + before: self.before, + after: self.after, + page_size: self.page_size, + status: self.status, + source: self.source, + tenant: self.tenant, + has_tenant: self.has_tenant, + archived: self.archived, + trigger_data: self.trigger_data + ) + + // check if the passed options are not nil + guard let options = options else { + return mergedOptions + } + + // for each one of the properties `not nil` in the parameter `options`, override the ones in the new struct + if options.before != nil { + mergedOptions.before = options.before + } + if options.after != nil { + mergedOptions.after = options.after + } + if options.page_size != nil { + mergedOptions.page_size = options.page_size + } + if options.status != nil { + mergedOptions.status = options.status + } + if options.source != nil { + mergedOptions.source = options.source + } + if options.tenant != nil { + mergedOptions.tenant = options.tenant + } + if options.has_tenant != nil { + mergedOptions.has_tenant = options.has_tenant + } + if options.archived != nil { + mergedOptions.archived = options.archived + } + if options.trigger_data != nil { + mergedOptions.trigger_data = options.trigger_data + } + + return mergedOptions + } + } +} diff --git a/Sources/Modules/Feed/Models/FeedItemScope.swift b/Sources/Modules/Feed/Models/FeedItemScope.swift new file mode 100644 index 0000000..c2f98ad --- /dev/null +++ b/Sources/Modules/Feed/Models/FeedItemScope.swift @@ -0,0 +1,40 @@ +// +// FeedItemScope.swift +// +// +// Created by Matt Gardner on 1/23/24. +// + +import Foundation + + +extension Knock.FeedManager { + public enum FeedItemScope: String, Codable { + // TODO: check engagement_status in https://docs.knock.app/reference#bulk-update-channel-message-status + // extras: + // case archived + // case unarchived + // case interacted + // minus "all" + case all + case unread + case read + case unseen + case seen + } + + public enum FeedItemArchivedScope: String, Codable { + case include + case exclude + case only + } + + public enum BulkChannelMessageStatusUpdateType: String { + case seen + case read + case archived + case unseen + case unread + case unarchived + } +} From db2feabe26d0a1f8501dbe95506072ae16cbf742 Mon Sep 17 00:00:00 2001 From: Matt Gardner Date: Wed, 24 Jan 2024 15:32:25 -0700 Subject: [PATCH 2/9] initial refactor --- Sources/Knock.swift | 103 +++++++++++++----- Sources/KnockAPI.swift | 39 +++---- Sources/Modules/Feed/FeedManager.swift | 134 ++---------------------- Sources/Modules/Users/UserService.swift | 27 ++++- 4 files changed, 130 insertions(+), 173 deletions(-) diff --git a/Sources/Knock.swift b/Sources/Knock.swift index fb9921d..f3f1817 100644 --- a/Sources/Knock.swift +++ b/Sources/Knock.swift @@ -7,37 +7,92 @@ import SwiftUI +//public class Knock { +// public let publishableKey: String +// public let userId: String +// public let userToken: String? +// +// internal let api: KnockAPI +// +// public var feedManager: FeedManager? +// +// public enum KnockError: Error { +// case runtimeError(String) +// } +// +// // MARK: Constructor +// +// /** +// Returns a new instance of the Knock Client +// +// - Parameters: +// - publishableKey: your public API key +// - userId: the user-id that will be used in the subsequent method calls +// - userToken: [optional] user token. Used in production when enhanced security is enabled +// - hostname: [optional] custom hostname of the API, including schema (https://) +// */ +// public init(publishableKey: String, userId: String, userToken: String? = nil, hostname: String? = nil) throws { +// guard publishableKey.hasPrefix("sk_") == false else { throw KnockError.runtimeError("[Knock] You are using your secret API key on the client. Please use the public key.") } +// +// self.publishableKey = publishableKey +// self.userId = userId +// self.userToken = userToken +// +// self.api = KnockAPI(publishableKey: publishableKey, userToken: userToken, hostname: hostname) +// } +//} + + +// Configuration options for the Knock client SDK. +public struct KnockOptions { + var host: String? +} + +// Knock client SDK. public class Knock { public let publishableKey: String - public let userId: String - public let userToken: String? - + public var userId: String? + public var userToken: String? + public var feedManager: FeedManager? + internal let api: KnockAPI + - public var feedManager: FeedManager? + public private(set) static var shared = Knock() - public enum KnockError: Error { - case runtimeError(String) + private let clientVersion = "1.0.0" + + internal var api: KnockAPI! + + public func initialize(apiKey: String, options: KnockOptions? = nil) { + + // Fail loudly if we're using the wrong API key + if apiKey.hasPrefix("sk") { + fatalError("[Knock] You are using your secret API key on the client. Please use the public key.") + } + self.api = KnockAPI(apiKey: apiKey, clientVersion: clientVersion, hostname: options?.host) } - // MARK: Constructor + private func assertInitialized() { + if api == nil { + fatalError("[Knock] You must call initialize() first before trying to make a request...") + } + } - /** - Returns a new instance of the Knock Client - - - Parameters: - - publishableKey: your public API key - - userId: the user-id that will be used in the subsequent method calls - - userToken: [optional] user token. Used in production when enhanced security is enabled - - hostname: [optional] custom hostname of the API, including schema (https://) - */ - public init(publishableKey: String, userId: String, userToken: String? = nil, hostname: String? = nil) throws { - guard publishableKey.hasPrefix("sk_") == false else { throw KnockError.runtimeError("[Knock] You are using your secret API key on the client. Please use the public key.") } - - self.publishableKey = publishableKey - self.userId = userId - self.userToken = userToken - - self.api = KnockAPI(publishableKey: publishableKey, userToken: userToken, hostname: hostname) + private func assertAuthenticated() { + if !isAuthenticated() { + fatalError("[Knock] You must call authenticate() first before trying to make a request...") + } + } + + private func assertAuthAndInit() { + assertInitialized() + assertAuthenticated() + } +} + +extension Knock { + public enum KnockError: Error { + case runtimeError(String) } } diff --git a/Sources/KnockAPI.swift b/Sources/KnockAPI.swift index d943749..a8117e9 100644 --- a/Sources/KnockAPI.swift +++ b/Sources/KnockAPI.swift @@ -8,51 +8,52 @@ import Foundation class KnockAPI { - private let publishableKey: String - private let userToken: String? - public var hostname = "https://api.knock.app" - private var apiBasePath: String { - "\(hostname)/v1" - } - - static let clientVersion = "0.2.0" + internal let apiKey: String + internal let clientVersion: String + internal let apiBasePath: String - init(publishableKey: String, userToken: String? = nil, hostname: String? = nil) { - self.publishableKey = publishableKey + internal var userToken: String? + internal var userId: String? + + internal var host = "https://api.knock.app" + + internal init(apiKey: String, clientVersion: String, hostname: String? = nil, userToken: String? = nil) { + self.apiKey = apiKey self.userToken = userToken - + self.clientVersion = clientVersion if let customHostname = hostname { - self.hostname = customHostname + self.host = customHostname } + apiBasePath = "\(host)/v1" } // MARK: Decode functions, they encapsulate making the request and decoding the data - func decodeFromGet(_ type: T.Type, path: String, queryItems: [URLQueryItem]?, then handler: @escaping (Result) -> Void) { + internal func decodeFromGet(_ type: T.Type, path: String, queryItems: [URLQueryItem]?, then handler: @escaping (Result) -> Void) { get(path: path, queryItems: queryItems) { (result) in self.decodeData(result, handler: handler) } } - func decodeFromPost(_ type: T.Type, path: String, body: Encodable?, then handler: @escaping (Result) -> Void) { + internal func decodeFromPost(_ type: T.Type, path: String, body: Encodable?, then handler: @escaping (Result) -> Void) { post(path: path, body: body) { (result) in self.decodeData(result, handler: handler) } } - func decodeFromPut(_ type: T.Type, path: String, body: Encodable?, then handler: @escaping (Result) -> Void) { + internal func decodeFromPut(_ type: T.Type, path: String, body: Encodable?, then handler: @escaping (Result) -> Void) { put(path: path, body: body) { (result) in self.decodeData(result, handler: handler) } } - func decodeFromDelete(_ type: T.Type, path: String, body: Encodable?, then handler: @escaping (Result) -> Void) { + internal func decodeFromDelete(_ type: T.Type, path: String, body: Encodable?, then handler: @escaping (Result) -> Void) { delete(path: path, body: body) { (result) in self.decodeData(result, handler: handler) } } - private func decodeData(_ result: Result, handler: @escaping (Result) -> Void) { + internal func decodeData(_ result: Result, handler: @escaping (Result) -> Void) { switch result { case .success(let data): let decoder = JSONDecoder() @@ -144,9 +145,9 @@ class KnockAPI { // Headers - request.addValue("knock-swift@\(KnockAPI.clientVersion)", forHTTPHeaderField: "User-Agent") + request.addValue("knock-swift@\(clientVersion)", forHTTPHeaderField: "User-Agent") - request.addValue("Bearer \(publishableKey)", forHTTPHeaderField: "Authorization") + request.addValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") if let userToken = userToken { request.addValue(userToken, forHTTPHeaderField: "X-Knock-User-Token") } diff --git a/Sources/Modules/Feed/FeedManager.swift b/Sources/Modules/Feed/FeedManager.swift index 1f25397..6d43ebf 100644 --- a/Sources/Modules/Feed/FeedManager.swift +++ b/Sources/Modules/Feed/FeedManager.swift @@ -19,138 +19,16 @@ public extension Knock { private var feedTopic: String private var defaultFeedOptions: FeedClientOptions - public enum FeedItemScope: String, Codable { - // TODO: check engagement_status in https://docs.knock.app/reference#bulk-update-channel-message-status - // extras: - // case archived - // case unarchived - // case interacted - // minus "all" - case all - case unread - case read - case unseen - case seen - } - - public enum FeedItemArchivedScope: String, Codable { - case include - case exclude - case only - } - - public struct FeedClientOptions: Codable { - public var before: String? - public var after: String? - public var page_size: Int? - public var status: FeedItemScope? - public var source: String? // Optionally scope all notifications to a particular source only - public var tenant: String? // Optionally scope all requests to a particular tenant - public var has_tenant: Bool? // Optionally scope to notifications with any tenancy or no tenancy - public var archived: FeedItemArchivedScope? // Optionally scope to a given archived status (defaults to `exclude`) - public var trigger_data: [String: AnyCodable]? // GenericData - - public init(from decoder: Decoder) throws { - let container: KeyedDecodingContainer = try decoder.container(keyedBy: Knock.FeedManager.FeedClientOptions.CodingKeys.self) - self.before = try container.decodeIfPresent(String.self, forKey: Knock.FeedManager.FeedClientOptions.CodingKeys.before) - self.after = try container.decodeIfPresent(String.self, forKey: Knock.FeedManager.FeedClientOptions.CodingKeys.after) - self.page_size = try container.decodeIfPresent(Int.self, forKey: Knock.FeedManager.FeedClientOptions.CodingKeys.page_size) - self.status = try container.decodeIfPresent(Knock.FeedManager.FeedItemScope.self, forKey: Knock.FeedManager.FeedClientOptions.CodingKeys.status) - self.source = try container.decodeIfPresent(String.self, forKey: Knock.FeedManager.FeedClientOptions.CodingKeys.source) - self.tenant = try container.decodeIfPresent(String.self, forKey: Knock.FeedManager.FeedClientOptions.CodingKeys.tenant) - self.has_tenant = try container.decodeIfPresent(Bool.self, forKey: Knock.FeedManager.FeedClientOptions.CodingKeys.has_tenant) - self.archived = try container.decodeIfPresent(Knock.FeedManager.FeedItemArchivedScope.self, forKey: Knock.FeedManager.FeedClientOptions.CodingKeys.archived) - self.trigger_data = try container.decodeIfPresent([String : AnyCodable].self, forKey: Knock.FeedManager.FeedClientOptions.CodingKeys.trigger_data) - } - - public init(before: String? = nil, after: String? = nil, page_size: Int? = nil, status: FeedItemScope? = nil, source: String? = nil, tenant: String? = nil, has_tenant: Bool? = nil, archived: FeedItemArchivedScope? = nil, trigger_data: [String : AnyCodable]? = nil) { - self.before = before - self.after = after - self.page_size = page_size - self.status = status - self.source = source - self.tenant = tenant - self.has_tenant = has_tenant - self.archived = archived - self.trigger_data = trigger_data - } - - /** - Returns a new struct of type `FeedClientOptions` with the options passed as the parameter merged into it. - - - Parameters: - - options: the options to merge with the current struct, if they are nil, only a copy of `self` will be returned - */ - public func mergeOptions(options: FeedClientOptions? = nil) -> FeedClientOptions { - // initialize a new `mergedOptions` struct with all the properties of the `self` struct - var mergedOptions = FeedClientOptions( - before: self.before, - after: self.after, - page_size: self.page_size, - status: self.status, - source: self.source, - tenant: self.tenant, - has_tenant: self.has_tenant, - archived: self.archived, - trigger_data: self.trigger_data - ) - - // check if the passed options are not nil - guard let options = options else { - return mergedOptions - } - - // for each one of the properties `not nil` in the parameter `options`, override the ones in the new struct - if options.before != nil { - mergedOptions.before = options.before - } - if options.after != nil { - mergedOptions.after = options.after - } - if options.page_size != nil { - mergedOptions.page_size = options.page_size - } - if options.status != nil { - mergedOptions.status = options.status - } - if options.source != nil { - mergedOptions.source = options.source - } - if options.tenant != nil { - mergedOptions.tenant = options.tenant - } - if options.has_tenant != nil { - mergedOptions.has_tenant = options.has_tenant - } - if options.archived != nil { - mergedOptions.archived = options.archived - } - if options.trigger_data != nil { - mergedOptions.trigger_data = options.trigger_data - } - - return mergedOptions - } - } - - public enum BulkChannelMessageStatusUpdateType: String { - case seen - case read - case archived - case unseen - case unread - case unarchived - } - - public init(client: Knock, feedId: String, options: FeedClientOptions = FeedClientOptions(archived: .exclude)) { + public init(feedId: String, options: FeedClientOptions = FeedClientOptions(archived: .exclude)) throws { + guard let userId = client.userId else { throw Knock.KnockError.runtimeError("Unable to initialize FeedManager without first authenticating Knock user.") } // use regex and circumflex accent to mark only the starting http to be replaced and not any others - let websocketHostname = client.api.hostname.replacingOccurrences(of: "^http", with: "ws", options: .regularExpression) // default: wss://api.knock.app + let websocketHostname = client.api.host.replacingOccurrences(of: "^http", with: "ws", options: .regularExpression) // default: wss://api.knock.app let websocketPath = "\(websocketHostname)/ws/v1/websocket" // default: wss://api.knock.app/ws/v1/websocket - self.socket = Socket(websocketPath, params: ["vsn": "2.0.0", "api_key": client.publishableKey, "user_token": client.userToken ?? ""]) - self.userId = client.userId + self.socket = Socket(websocketPath, params: ["vsn": "2.0.0", "api_key": client.api.apiKey, "user_token": client.api.userToken ?? ""]) + self.userId = userId self.feedId = feedId - self.feedTopic = "feeds:\(feedId):\(client.userId)" + self.feedTopic = "feeds:\(feedId):\(userId)" self.api = client.api self.defaultFeedOptions = options } diff --git a/Sources/Modules/Users/UserService.swift b/Sources/Modules/Users/UserService.swift index 866b2f1..ce50932 100644 --- a/Sources/Modules/Users/UserService.swift +++ b/Sources/Modules/Users/UserService.swift @@ -8,11 +8,34 @@ import Foundation public extension Knock { + + func authenticate(userId: String, userToken: String? = nil, deviceToken: String? = nil, pushChannelId: String? = nil) { + self.api?.userId = userId + self.api?.userToken = userToken + if let token = deviceToken, let channelId = pushChannelId { + self.registerTokenForAPNS(channelId: channelId, token: token) { result in + + } + } + } + + func isAuthenticated(checkUserToken: Bool = false) -> Bool { + if checkUserToken { + return self.api.userId?.isEmpty == false && self.api.userToken?.isEmpty == false + } + return self.api.userId?.isEmpty == false + } + + func logout() { + self.api?.userId = nil + self.api?.userToken = nil + } + func getUser(completionHandler: @escaping ((Result) -> Void)) { - self.api.decodeFromGet(User.self, path: "/users/\(userId)", queryItems: nil, then: completionHandler) + self.api?.decodeFromGet(User.self, path: "/users/\(userId)", queryItems: nil, then: completionHandler) } func updateUser(user: User, completionHandler: @escaping ((Result) -> Void)) { - self.api.decodeFromPut(User.self, path: "/users/\(userId)", body: user, then: completionHandler) + self.api?.decodeFromPut(User.self, path: "/users/\(userId)", body: user, then: completionHandler) } } From 3ec09b2561268ca4abf54976f0e97b0768bc2381 Mon Sep 17 00:00:00 2001 From: Matt Gardner Date: Wed, 24 Jan 2024 16:40:23 -0700 Subject: [PATCH 3/9] Implemented shared Knock instance --- Sources/Knock.swift | 58 ++++++++++++------- Sources/KnockAPI.swift | 29 +++++----- Sources/KnockAppDelegate.swift | 2 +- Sources/Modules/Channels/ChannelService.swift | 12 ++-- Sources/Modules/Feed/FeedManager.swift | 37 ++++++------ .../Preferences/PreferenceService.swift | 6 +- Sources/Modules/Users/UserService.swift | 28 ++++++--- 7 files changed, 94 insertions(+), 78 deletions(-) diff --git a/Sources/Knock.swift b/Sources/Knock.swift index f3f1817..13dcf16 100644 --- a/Sources/Knock.swift +++ b/Sources/Knock.swift @@ -50,19 +50,19 @@ public struct KnockOptions { // Knock client SDK. public class Knock { - public let publishableKey: String - public var userId: String? - public var userToken: String? - public var feedManager: FeedManager? - - internal let api: KnockAPI - + internal static let clientVersion = "1.0.0" + internal static let loggingSubsytem = "knock-swift" public private(set) static var shared = Knock() - - private let clientVersion = "1.0.0" + + public private(set) var publishableKey: String? +// public private(set) var userId: String? + public internal(set) var userId: String? + public internal(set) var userToken: String? + public var feedManager: FeedManager? + - internal var api: KnockAPI! + internal private(set) var api: KnockAPI! public func initialize(apiKey: String, options: KnockOptions? = nil) { @@ -70,24 +70,38 @@ public class Knock { if apiKey.hasPrefix("sk") { fatalError("[Knock] You are using your secret API key on the client. Please use the public key.") } - self.api = KnockAPI(apiKey: apiKey, clientVersion: clientVersion, hostname: options?.host) + self.api = KnockAPI(apiKey: apiKey, hostname: options?.host) } - private func assertInitialized() { - if api == nil { - fatalError("[Knock] You must call initialize() first before trying to make a request...") - } - } +// private func assertInitialized() { +// if api == nil { +// fatalError("[Knock] You must call initialize() first before trying to make a request...") +// } +// } +// +// private func assertAuthenticated() { +// if !isAuthenticated() { +// fatalError("[Knock] You must call authenticate() first before trying to make a request...") +// } +// } +// +// private func assertAuthAndInit() { +// assertInitialized() +// assertAuthenticated() +// } - private func assertAuthenticated() { - if !isAuthenticated() { - fatalError("[Knock] You must call authenticate() first before trying to make a request...") + internal var safePublishableKey: String { + guard let key = publishableKey else { + fatalError("[Knock] You must call Knock.shared.initialize() first before trying to make a request...") } + return key } - private func assertAuthAndInit() { - assertInitialized() - assertAuthenticated() + internal var safeUserId: String { + guard let id = userId else { + fatalError("[Knock] You must call Knock.shared.authenticate() first before trying to make a request where userId is required...") + } + return id } } diff --git a/Sources/KnockAPI.swift b/Sources/KnockAPI.swift index a8117e9..7c04ebf 100644 --- a/Sources/KnockAPI.swift +++ b/Sources/KnockAPI.swift @@ -8,23 +8,15 @@ import Foundation class KnockAPI { - internal let apiKey: String - internal let clientVersion: String - internal let apiBasePath: String - - internal var userToken: String? - internal var userId: String? - - internal var host = "https://api.knock.app" + internal private(set) var host = "https://api.knock.app" + private var apiBasePath: String { + "\(host)/v1" + } - internal init(apiKey: String, clientVersion: String, hostname: String? = nil, userToken: String? = nil) { - self.apiKey = apiKey - self.userToken = userToken - self.clientVersion = clientVersion + internal init(apiKey: String, hostname: String? = nil) { if let customHostname = hostname { self.host = customHostname } - apiBasePath = "\(host)/v1" } // MARK: Decode functions, they encapsulate making the request and decoding the data @@ -124,6 +116,13 @@ class KnockAPI { - then: the code to execute when the response is received */ private func makeGeneralRequest(method: String, path: String, queryItems: [URLQueryItem]?, body: Encodable?, then handler: @escaping (Result) -> Void) { + guard let apiKey = Knock.shared.publishableKey else { + DispatchQueue.main.async { + handler(.failure(Knock.KnockError.runtimeError("Can't make request until Knock.shared.initialize has been called."))) + } + return + } + let sessionConfig = URLSessionConfiguration.default let session = URLSession(configuration: sessionConfig, delegate: nil, delegateQueue: nil) guard var URL = URL(string: "\(apiBasePath)\(path)") else {return} @@ -145,10 +144,10 @@ class KnockAPI { // Headers - request.addValue("knock-swift@\(clientVersion)", forHTTPHeaderField: "User-Agent") + request.addValue("knock-swift@\(Knock.shared.clientVersion)", forHTTPHeaderField: "User-Agent") request.addValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") - if let userToken = userToken { + if let userToken = Knock.shared.userToken { request.addValue(userToken, forHTTPHeaderField: "X-Knock-User-Token") } diff --git a/Sources/KnockAppDelegate.swift b/Sources/KnockAppDelegate.swift index 51c03a6..6b68def 100644 --- a/Sources/KnockAppDelegate.swift +++ b/Sources/KnockAppDelegate.swift @@ -74,7 +74,7 @@ open class KnockAppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificat let defaults = UserDefaults.standard defaults.set(token, forKey: "device_push_token") - deviceTokenDidChange(apnsToken: token, isDebugging: isDebuggerAttached) +// deviceTokenDidChange(apnsToken: token, isDebugging: isDebuggerAttached) // self.pushToken = token } diff --git a/Sources/Modules/Channels/ChannelService.swift b/Sources/Modules/Channels/ChannelService.swift index ca52093..b62ec91 100644 --- a/Sources/Modules/Channels/ChannelService.swift +++ b/Sources/Modules/Channels/ChannelService.swift @@ -6,11 +6,12 @@ // import Foundation +import OSLog public extension Knock { - + func getUserChannelData(channelId: String, completionHandler: @escaping ((Result) -> Void)) { - self.api.decodeFromGet(ChannelData.self, path: "/users/\(userId)/channel_data/\(channelId)", queryItems: nil, then: completionHandler) + self.api.decodeFromGet(ChannelData.self, path: "/users/\(self.safeUserId)/channel_data/\(channelId)", queryItems: nil, then: completionHandler) } /** @@ -24,7 +25,7 @@ public extension Knock { let payload = [ "data": data ] - self.api.decodeFromPut(ChannelData.self, path: "/users/\(userId)/channel_data/\(channelId)", body: payload, then: completionHandler) + self.api.decodeFromPut(ChannelData.self, path: "/users/\(self.safeUserId)/channel_data/\(channelId)", body: payload, then: completionHandler) } /** @@ -64,7 +65,6 @@ public extension Knock { switch result { case .failure(_): // there's no data registered on that channel for that user, we'll create a new record - print("there's no data registered on that channel for that user, we'll create a new record") let data: AnyEncodable = [ "tokens": [token] ] @@ -72,7 +72,6 @@ public extension Knock { case .success(let channelData): guard let data = channelData.data else { // we don't have data for that channel for that user, we'll create a new record - print("we don't have data for that channel for that user, we'll create a new record") let data: AnyEncodable = [ "tokens": [token] ] @@ -82,7 +81,6 @@ public extension Knock { guard var tokens = data["tokens"]?.value as? [String] else { // we don't have an array of valid tokens so we'll register a new one - print("we don't have an array of valid tokens so we'll register a new one") let data: AnyEncodable = [ "tokens": [token] ] @@ -92,12 +90,10 @@ public extension Knock { if tokens.contains(token) { // we already have the token registered - print("we already have the token registered") completionHandler(.success(channelData)) } else { // we need to register the token - print("we need to register the token") tokens.append(token) let data: AnyEncodable = [ "tokens": tokens diff --git a/Sources/Modules/Feed/FeedManager.swift b/Sources/Modules/Feed/FeedManager.swift index 6d43ebf..ce79ac6 100644 --- a/Sources/Modules/Feed/FeedManager.swift +++ b/Sources/Modules/Feed/FeedManager.swift @@ -7,29 +7,26 @@ import Foundation import SwiftPhoenixClient +import OSLog public extension Knock { class FeedManager { - private let api: KnockAPI private let socket: Socket private var feedChannel: Channel? - private let userId: String private let feedId: String private var feedTopic: String private var defaultFeedOptions: FeedClientOptions + private let logger: Logger = Logger(subsystem: Knock.loggingSubsytem, category: "FeedManager") public init(feedId: String, options: FeedClientOptions = FeedClientOptions(archived: .exclude)) throws { - guard let userId = client.userId else { throw Knock.KnockError.runtimeError("Unable to initialize FeedManager without first authenticating Knock user.") } // use regex and circumflex accent to mark only the starting http to be replaced and not any others - let websocketHostname = client.api.host.replacingOccurrences(of: "^http", with: "ws", options: .regularExpression) // default: wss://api.knock.app + let websocketHostname = Knock.shared.api.host.replacingOccurrences(of: "^http", with: "ws", options: .regularExpression) // default: wss://api.knock.app let websocketPath = "\(websocketHostname)/ws/v1/websocket" // default: wss://api.knock.app/ws/v1/websocket - self.socket = Socket(websocketPath, params: ["vsn": "2.0.0", "api_key": client.api.apiKey, "user_token": client.api.userToken ?? ""]) - self.userId = userId + self.socket = Socket(websocketPath, params: ["vsn": "2.0.0", "api_key": Knock.shared.safePublishableKey, "user_token": Knock.shared.userToken ?? ""]) self.feedId = feedId - self.feedTopic = "feeds:\(feedId):\(userId)" - self.api = client.api + self.feedTopic = "feeds:\(feedId):\(Knock.shared.safeUserId)" self.defaultFeedOptions = options } @@ -42,24 +39,24 @@ public extension Knock { public func connectToFeed(options: FeedClientOptions? = nil) { // Setup the socket to receive open/close events socket.delegateOnOpen(to: self) { (self) in - print("Socket Opened") + self.logger.debug("[Knock] Socket Opened") } socket.delegateOnClose(to: self) { (self) in - print("Socket Closed") + self.logger.debug("[Knock] Socket Closed") } socket.delegateOnError(to: self) { (self, error) in let (error, response) = error if let statusCode = (response as? HTTPURLResponse)?.statusCode, statusCode > 400 { - print("Socket Errored. \(statusCode)") + self.logger.error("[Knock] Socket Errored. \(statusCode)") self.socket.disconnect() } else { - print("Socket Errored. \(error)") + self.logger.error("[Knock] Socket Errored. \(error)") } } - socket.logger = { msg in print("LOG:", msg) } +// socket.logger = { msg in print("LOG:", msg) } let mergedOptions = defaultFeedOptions.mergeOptions(options: options) @@ -73,10 +70,10 @@ public extension Knock { self.feedChannel? .join() .delegateReceive("ok", to: self) { (self, _) in - print("CHANNEL: \(channel.topic) joined") + self.logger.debug("[Knock] CHANNEL: \(channel.topic) joined") } .delegateReceive("error", to: self) { (self, message) in - print("CHANNEL: \(channel.topic) failed to join. \(message.payload)") + self.logger.debug("[Knock] CHANNEL: \(channel.topic) failed to join. \(message.payload)") } self.socket.connect() @@ -89,12 +86,12 @@ public extension Knock { } } else { - print("Feed channel is nil. You should call first connectToFeed()") + logger.error("[Knock] Feed channel is nil. You should call first connectToFeed()") } } public func disconnectFromFeed() { - print("Disconnecting from feed") + logger.debug("[Knock] Disconnecting from feed") if let channel = self.feedChannel { channel.leave() @@ -128,7 +125,7 @@ public extension Knock { URLQueryItem(name: "trigger_data", value: triggerDataJSON) ] - api.decodeFromGet(Feed.self, path: "/users/\(userId)/feeds/\(feedId)", queryItems: queryItems) { (result) in + Knock.shared.api.decodeFromGet(Feed.self, path: "/users/\(Knock.shared.safeUserId)/feeds/\(feedId)", queryItems: queryItems) { (result) in completionHandler(result) } } @@ -151,14 +148,14 @@ public extension Knock { // engagement_status: one of `seen`, `unseen`, `read`, `unread`, `archived`, `unarchived`, `interacted` // Also check if the parameters sent here are valid let body: AnyEncodable = [ - "user_ids": [userId], + "user_ids": [Knock.shared.safeUserId], "engagement_status": options.status != nil && options.status != .all ? options.status!.rawValue : "", "archived": options.archived ?? "", "has_tenant": options.has_tenant ?? "", "tenants": (options.tenant != nil) ? [options.tenant!] : "" ] - api.decodeFromPost(BulkOperation.self, path: "/channels/\(feedId)/messages/bulk/\(type.rawValue)", body: body, then: completionHandler) + Knock.shared.api.decodeFromPost(BulkOperation.self, path: "/channels/\(feedId)/messages/bulk/\(type.rawValue)", body: body, then: completionHandler) } private func paramsFromOptions(options: FeedClientOptions) -> [String: Any] { diff --git a/Sources/Modules/Preferences/PreferenceService.swift b/Sources/Modules/Preferences/PreferenceService.swift index f0d3518..b876d29 100644 --- a/Sources/Modules/Preferences/PreferenceService.swift +++ b/Sources/Modules/Preferences/PreferenceService.swift @@ -10,15 +10,15 @@ import Foundation public extension Knock { func getAllUserPreferences(completionHandler: @escaping ((Result<[PreferenceSet], Error>) -> Void)) { - self.api.decodeFromGet([PreferenceSet].self, path: "/users/\(userId)/preferences", queryItems: nil, then: completionHandler) + self.api.decodeFromGet([PreferenceSet].self, path: "/users/\(self.safeUserId)/preferences", queryItems: nil, then: completionHandler) } func getUserPreferences(preferenceId: String, completionHandler: @escaping ((Result) -> Void)) { - self.api.decodeFromGet(PreferenceSet.self, path: "/users/\(userId)/preferences/\(preferenceId)", queryItems: nil, then: completionHandler) + self.api.decodeFromGet(PreferenceSet.self, path: "/users/\(self.safeUserId)/preferences/\(preferenceId)", queryItems: nil, then: completionHandler) } func setUserPreferences(preferenceId: String, preferenceSet: PreferenceSet, completionHandler: @escaping ((Result) -> Void)) { let payload = preferenceSet - self.api.decodeFromPut(PreferenceSet.self, path: "/users/\(userId)/preferences/\(preferenceId)", body: payload, then: completionHandler) + self.api.decodeFromPut(PreferenceSet.self, path: "/users/\(self.safeUserId)/preferences/\(preferenceId)", body: payload, then: completionHandler) } } diff --git a/Sources/Modules/Users/UserService.swift b/Sources/Modules/Users/UserService.swift index ce50932..7476580 100644 --- a/Sources/Modules/Users/UserService.swift +++ b/Sources/Modules/Users/UserService.swift @@ -6,36 +6,46 @@ // import Foundation +import OSLog public extension Knock { + private var logger: Logger { + Logger(subsystem: Knock.loggingSubsytem, category: "UserService") + } + func authenticate(userId: String, userToken: String? = nil, deviceToken: String? = nil, pushChannelId: String? = nil) { - self.api?.userId = userId - self.api?.userToken = userToken + self.userId = userId + self.userToken = userToken if let token = deviceToken, let channelId = pushChannelId { self.registerTokenForAPNS(channelId: channelId, token: token) { result in - + switch result { + case .success(_): + self.logger.debug("success registering the push token with Knock") + case .failure(let error): + self.logger.error("error in registerTokenForAPNS: \(error.localizedDescription)") + } } } } func isAuthenticated(checkUserToken: Bool = false) -> Bool { if checkUserToken { - return self.api.userId?.isEmpty == false && self.api.userToken?.isEmpty == false + return self.userId?.isEmpty == false && self.userToken?.isEmpty == false } - return self.api.userId?.isEmpty == false + return self.userId?.isEmpty == false } func logout() { - self.api?.userId = nil - self.api?.userToken = nil + self.userId = nil + self.userToken = nil } func getUser(completionHandler: @escaping ((Result) -> Void)) { - self.api?.decodeFromGet(User.self, path: "/users/\(userId)", queryItems: nil, then: completionHandler) + self.api?.decodeFromGet(User.self, path: "/users/\(self.safeUserId)", queryItems: nil, then: completionHandler) } func updateUser(user: User, completionHandler: @escaping ((Result) -> Void)) { - self.api?.decodeFromPut(User.self, path: "/users/\(userId)", body: user, then: completionHandler) + self.api?.decodeFromPut(User.self, path: "/users/\(self.safeUserId)", body: user, then: completionHandler) } } From 630ca4dd527f46bf50fbf66884489f24ac716bb4 Mon Sep 17 00:00:00 2001 From: Matt Gardner Date: Thu, 25 Jan 2024 15:36:31 -0700 Subject: [PATCH 4/9] added device unregistration functionality --- Sources/Helpers/Utilities.swift | 7 ++ Sources/Knock.swift | 111 +++++------------- Sources/KnockAPI.swift | 24 ++-- Sources/Modules/Channels/ChannelService.swift | 97 ++++++++++----- Sources/Modules/Feed/FeedManager.swift | 21 ++-- Sources/Modules/Users/UserService.swift | 51 ++++++-- 6 files changed, 176 insertions(+), 135 deletions(-) diff --git a/Sources/Helpers/Utilities.swift b/Sources/Helpers/Utilities.swift index 16bca0c..e8599a5 100644 --- a/Sources/Helpers/Utilities.swift +++ b/Sources/Helpers/Utilities.swift @@ -26,6 +26,13 @@ internal extension Knock { return jsonString } + + static func convertTokenToString(token: Data) -> String { + let tokenParts = token.map { data -> String in + return String(format: "%02.2hhx", data) + } + return tokenParts.joined() + } } struct DynamicCodingKey: CodingKey { diff --git a/Sources/Knock.swift b/Sources/Knock.swift index 13dcf16..759ea4c 100644 --- a/Sources/Knock.swift +++ b/Sources/Knock.swift @@ -7,94 +7,37 @@ import SwiftUI -//public class Knock { -// public let publishableKey: String -// public let userId: String -// public let userToken: String? -// -// internal let api: KnockAPI -// -// public var feedManager: FeedManager? -// -// public enum KnockError: Error { -// case runtimeError(String) -// } -// -// // MARK: Constructor -// -// /** -// Returns a new instance of the Knock Client -// -// - Parameters: -// - publishableKey: your public API key -// - userId: the user-id that will be used in the subsequent method calls -// - userToken: [optional] user token. Used in production when enhanced security is enabled -// - hostname: [optional] custom hostname of the API, including schema (https://) -// */ -// public init(publishableKey: String, userId: String, userToken: String? = nil, hostname: String? = nil) throws { -// guard publishableKey.hasPrefix("sk_") == false else { throw KnockError.runtimeError("[Knock] You are using your secret API key on the client. Please use the public key.") } -// -// self.publishableKey = publishableKey -// self.userId = userId -// self.userToken = userToken -// -// self.api = KnockAPI(publishableKey: publishableKey, userToken: userToken, hostname: hostname) -// } -//} - - -// Configuration options for the Knock client SDK. -public struct KnockOptions { - var host: String? -} - // Knock client SDK. public class Knock { internal static let clientVersion = "1.0.0" internal static let loggingSubsytem = "knock-swift" - public private(set) static var shared = Knock() - - public private(set) var publishableKey: String? -// public private(set) var userId: String? - public internal(set) var userId: String? - public internal(set) var userToken: String? + internal var api: KnockAPI + public var feedManager: FeedManager? + public internal(set) var userId: String? + public internal(set) var pushChannelId: String? + public internal(set) var userDeviceToken: String? - - internal private(set) var api: KnockAPI! - - public func initialize(apiKey: String, options: KnockOptions? = nil) { - - // Fail loudly if we're using the wrong API key - if apiKey.hasPrefix("sk") { - fatalError("[Knock] You are using your secret API key on the client. Please use the public key.") - } - self.api = KnockAPI(apiKey: apiKey, hostname: options?.host) + /** + Returns a new instance of the Knock Client + + - Parameters: + - publishableKey: your public API key + - userId: the user-id that will be used in the subsequent method calls + - userToken: [optional] user token. Used in production when enhanced security is enabled + - options: [optional] Options for customizing the Knock instance. + */ + public init(publishableKey: String, options: KnockOptions?) { + self.api = KnockAPI(publishableKey: publishableKey, hostname: options?.host) } -// private func assertInitialized() { -// if api == nil { -// fatalError("[Knock] You must call initialize() first before trying to make a request...") -// } -// } -// -// private func assertAuthenticated() { -// if !isAuthenticated() { -// fatalError("[Knock] You must call authenticate() first before trying to make a request...") -// } -// } -// -// private func assertAuthAndInit() { -// assertInitialized() -// assertAuthenticated() -// } - - internal var safePublishableKey: String { - guard let key = publishableKey else { - fatalError("[Knock] You must call Knock.shared.initialize() first before trying to make a request...") - } - return key + internal func resetInstance() { + self.userId = nil + self.feedManager = nil + self.userDeviceToken = nil + self.pushChannelId = nil + self.api.updateUserInfo(userToken: nil) } internal var safeUserId: String { @@ -106,7 +49,17 @@ public class Knock { } extension Knock { + // Configuration options for the Knock client SDK. + public struct KnockOptions { + var host: String? + } + public enum KnockError: Error { case runtimeError(String) } } + + +// NoTES: +// Should we provide more safety around userID being invalid? Instead of fatal erroring out the app. +// Ensure that switching api to struct is the right move. diff --git a/Sources/KnockAPI.swift b/Sources/KnockAPI.swift index 7c04ebf..be0668e 100644 --- a/Sources/KnockAPI.swift +++ b/Sources/KnockAPI.swift @@ -7,16 +7,24 @@ import Foundation -class KnockAPI { +struct KnockAPI { + internal let publishableKey: String internal private(set) var host = "https://api.knock.app" + public internal(set) var userToken: String? + private var apiBasePath: String { "\(host)/v1" } - internal init(apiKey: String, hostname: String? = nil) { + internal init(publishableKey: String, hostname: String? = nil) { if let customHostname = hostname { self.host = customHostname } + self.publishableKey = publishableKey + } + + internal mutating func updateUserInfo(userToken: String?) { + self.userToken = userToken } // MARK: Decode functions, they encapsulate making the request and decoding the data @@ -116,12 +124,6 @@ class KnockAPI { - then: the code to execute when the response is received */ private func makeGeneralRequest(method: String, path: String, queryItems: [URLQueryItem]?, body: Encodable?, then handler: @escaping (Result) -> Void) { - guard let apiKey = Knock.shared.publishableKey else { - DispatchQueue.main.async { - handler(.failure(Knock.KnockError.runtimeError("Can't make request until Knock.shared.initialize has been called."))) - } - return - } let sessionConfig = URLSessionConfiguration.default let session = URLSession(configuration: sessionConfig, delegate: nil, delegateQueue: nil) @@ -144,10 +146,10 @@ class KnockAPI { // Headers - request.addValue("knock-swift@\(Knock.shared.clientVersion)", forHTTPHeaderField: "User-Agent") + request.addValue("knock-swift@\(Knock.clientVersion)", forHTTPHeaderField: "User-Agent") - request.addValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") - if let userToken = Knock.shared.userToken { + request.addValue("Bearer \(publishableKey)", forHTTPHeaderField: "Authorization") + if let userToken = userToken { request.addValue(userToken, forHTTPHeaderField: "X-Knock-User-Token") } diff --git a/Sources/Modules/Channels/ChannelService.swift b/Sources/Modules/Channels/ChannelService.swift index b62ec91..96d955a 100644 --- a/Sources/Modules/Channels/ChannelService.swift +++ b/Sources/Modules/Channels/ChannelService.swift @@ -9,7 +9,10 @@ import Foundation import OSLog public extension Knock { - + private var logger: Logger { + Logger(subsystem: Knock.loggingSubsytem, category: "ChannelService") + } + func getUserChannelData(channelId: String, completionHandler: @escaping ((Result) -> Void)) { self.api.decodeFromGet(ChannelData.self, path: "/users/\(self.safeUserId)/channel_data/\(channelId)", queryItems: nil, then: completionHandler) } @@ -28,6 +31,8 @@ public extension Knock { self.api.decodeFromPut(ChannelData.self, path: "/users/\(self.safeUserId)/channel_data/\(channelId)", body: payload, then: completionHandler) } + // Mark: Registration of APNS device tokens + /** Registers an Apple Push Notification Service token so that the device can receive remote push notifications. This is a convenience method that internally gets the channel data and searches for the token. If it exists, then it's already registered and it returns. If the data does not exists or the token is missing from the array, it's added. @@ -41,10 +46,7 @@ public extension Knock { */ func registerTokenForAPNS(channelId: String, token: Data, completionHandler: @escaping ((Result) -> Void)) { // 1. Convert device token to string - let tokenParts = token.map { data -> String in - return String(format: "%02.2hhx", data) - } - let tokenString = tokenParts.joined() + let tokenString = Knock.convertTokenToString(token: token) registerTokenForAPNS(channelId: channelId, token: tokenString, completionHandler: completionHandler) } @@ -61,44 +63,81 @@ public extension Knock { - token: the APNS device token as a `String` */ func registerTokenForAPNS(channelId: String, token: String, completionHandler: @escaping ((Result) -> Void)) { + // Closure to handle token registration/update + let registerOrUpdateToken = { [weak self] (existingTokens: [String]?) in + guard let self = self else { return } + var tokens = existingTokens ?? [] + if !tokens.contains(token) { + tokens.append(token) + } + let data: AnyEncodable = ["tokens": tokens] + self.updateUserChannelData(channelId: channelId, data: data, completionHandler: completionHandler) + } + getUserChannelData(channelId: channelId) { result in switch result { case .failure(_): - // there's no data registered on that channel for that user, we'll create a new record - let data: AnyEncodable = [ - "tokens": [token] - ] - self.updateUserChannelData(channelId: channelId, data: data, completionHandler: completionHandler) + // No data registered on that channel for that user, we'll create a new record + registerOrUpdateToken(nil) + completionHandler(.success(ChannelData.init(channel_id: channelId, data: [:]))) case .success(let channelData): - guard let data = channelData.data else { - // we don't have data for that channel for that user, we'll create a new record - let data: AnyEncodable = [ - "tokens": [token] - ] - self.updateUserChannelData(channelId: channelId, data: data, completionHandler: completionHandler) + guard let data = channelData.data, let tokens = data["tokens"]?.value as? [String] else { + // No valid tokens array found, register a new one + registerOrUpdateToken(nil) return } - guard var tokens = data["tokens"]?.value as? [String] else { - // we don't have an array of valid tokens so we'll register a new one - let data: AnyEncodable = [ - "tokens": [token] - ] - self.updateUserChannelData(channelId: channelId, data: data, completionHandler: completionHandler) - return + if tokens.contains(token) { + // Token already registered + completionHandler(.success(channelData)) + } else { + // Register the new token + registerOrUpdateToken(tokens) + } + } + } + } + + /** + Unregisters the current deviceId associated to the user so that the device will no longer receive remote push notifications for the provided channelId. + + - Parameters: + - channelId: the id of the APNS channel in Knock + - token: the APNS device token as a `String` + */ + + func unregisterTokenForAPNS(channelId: String, token: String, completionHandler: @escaping ((Result) -> Void)) { + getUserChannelData(channelId: channelId) { result in + switch result { + case .failure(let error): + if let networkError = error as? NetworkError, networkError.code == 404 { + // No data registered on that channel for that user + self.logger.warning("[Knock] Could not unregister user from channel \(channelId). Reason: User doesn't have any channel data associated to the provided channelId.") + completionHandler(.success(.init(channel_id: channelId, data: [:]))) + } else { + // Unknown error. Could be network or server related. Try again. + self.logger.error("[Knock] Could not unregister user from channel \(channelId). Please try again. Reason: \(error.localizedDescription)") + completionHandler(.failure(error)) } - if tokens.contains(token) { - // we already have the token registered + case .success(let channelData): + guard let data = channelData.data, let tokens = data["tokens"]?.value as? [String] else { + // No valid tokens array found. + self.logger.warning("[Knock] Could not unregister user from channel \(channelId). Reason: User doesn't have any device tokens associated to the provided channelId.") completionHandler(.success(channelData)) + return } - else { - // we need to register the token - tokens.append(token) + + if tokens.contains(token) { + let newTokensSet = Set(tokens).subtracting([token]) + let newTokens = Array(newTokensSet) let data: AnyEncodable = [ - "tokens": tokens + "tokens": newTokens ] self.updateUserChannelData(channelId: channelId, data: data, completionHandler: completionHandler) + } else { + self.logger.warning("[Knock] Could not unregister user from channel \(channelId). Reason: User doesn't have any device tokens that match the token provided.") + completionHandler(.success(channelData)) } } } diff --git a/Sources/Modules/Feed/FeedManager.swift b/Sources/Modules/Feed/FeedManager.swift index ce79ac6..d72200f 100644 --- a/Sources/Modules/Feed/FeedManager.swift +++ b/Sources/Modules/Feed/FeedManager.swift @@ -10,8 +10,13 @@ import SwiftPhoenixClient import OSLog public extension Knock { + func initializeFeedManager(feedId: String, options: FeedManager.FeedClientOptions = FeedManager.FeedClientOptions(archived: .exclude)) { + self.feedManager = FeedManager(api: self.api, userId: self.safeUserId, feedId: feedId, options: options) + } class FeedManager { + private let api: KnockAPI + private let userId: String private let socket: Socket private var feedChannel: Channel? private let feedId: String @@ -19,14 +24,16 @@ public extension Knock { private var defaultFeedOptions: FeedClientOptions private let logger: Logger = Logger(subsystem: Knock.loggingSubsytem, category: "FeedManager") - public init(feedId: String, options: FeedClientOptions = FeedClientOptions(archived: .exclude)) throws { + internal init(api: KnockAPI, userId: String, feedId: String, options: FeedClientOptions = FeedClientOptions(archived: .exclude)) { // use regex and circumflex accent to mark only the starting http to be replaced and not any others - let websocketHostname = Knock.shared.api.host.replacingOccurrences(of: "^http", with: "ws", options: .regularExpression) // default: wss://api.knock.app + let websocketHostname = api.host.replacingOccurrences(of: "^http", with: "ws", options: .regularExpression) // default: wss://api.knock.app let websocketPath = "\(websocketHostname)/ws/v1/websocket" // default: wss://api.knock.app/ws/v1/websocket - self.socket = Socket(websocketPath, params: ["vsn": "2.0.0", "api_key": Knock.shared.safePublishableKey, "user_token": Knock.shared.userToken ?? ""]) + self.api = api + self.userId = userId + self.socket = Socket(websocketPath, params: ["vsn": "2.0.0", "api_key": api.publishableKey, "user_token": api.userToken ?? ""]) self.feedId = feedId - self.feedTopic = "feeds:\(feedId):\(Knock.shared.safeUserId)" + self.feedTopic = "feeds:\(feedId):\(userId)" self.defaultFeedOptions = options } @@ -125,7 +132,7 @@ public extension Knock { URLQueryItem(name: "trigger_data", value: triggerDataJSON) ] - Knock.shared.api.decodeFromGet(Feed.self, path: "/users/\(Knock.shared.safeUserId)/feeds/\(feedId)", queryItems: queryItems) { (result) in + api.decodeFromGet(Feed.self, path: "/users/\(userId)/feeds/\(feedId)", queryItems: queryItems) { (result) in completionHandler(result) } } @@ -148,14 +155,14 @@ public extension Knock { // engagement_status: one of `seen`, `unseen`, `read`, `unread`, `archived`, `unarchived`, `interacted` // Also check if the parameters sent here are valid let body: AnyEncodable = [ - "user_ids": [Knock.shared.safeUserId], + "user_ids": [userId], "engagement_status": options.status != nil && options.status != .all ? options.status!.rawValue : "", "archived": options.archived ?? "", "has_tenant": options.has_tenant ?? "", "tenants": (options.tenant != nil) ? [options.tenant!] : "" ] - Knock.shared.api.decodeFromPost(BulkOperation.self, path: "/channels/\(feedId)/messages/bulk/\(type.rawValue)", body: body, then: completionHandler) + api.decodeFromPost(BulkOperation.self, path: "/channels/\(feedId)/messages/bulk/\(type.rawValue)", body: body, then: completionHandler) } private func paramsFromOptions(options: FeedClientOptions) -> [String: Any] { diff --git a/Sources/Modules/Users/UserService.swift b/Sources/Modules/Users/UserService.swift index 7476580..38bf7a1 100644 --- a/Sources/Modules/Users/UserService.swift +++ b/Sources/Modules/Users/UserService.swift @@ -14,16 +14,32 @@ public extension Knock { Logger(subsystem: Knock.loggingSubsytem, category: "UserService") } - func authenticate(userId: String, userToken: String? = nil, deviceToken: String? = nil, pushChannelId: String? = nil) { + /** + Sets the user for the current Knock instance. Will also register the device for push notifications if token and channelId are provided. + You can also register the device for push notifications later on by calling Knock.registerTokenForAPNS() + + - Parameters: + - userId: the user-id that will be used in the subsequent method calls + - userToken: [optional] user token. Used in production when enhanced security is enabled + - deviceToken: [optional] Options for customizing the Knock instance. + - pushChannelId: [optional] Options for customizing the Knock instance. + */ + func authenticate(userId: String, userToken: String? = nil, deviceToken: String? = nil, pushChannelId: String? = nil, completion: @escaping (Result) -> Void) { self.userId = userId - self.userToken = userToken + self.pushChannelId = pushChannelId + self.userDeviceToken = deviceToken + self.api.updateUserInfo(userToken: userToken) if let token = deviceToken, let channelId = pushChannelId { self.registerTokenForAPNS(channelId: channelId, token: token) { result in switch result { case .success(_): + self.pushChannelId = pushChannelId + self.userDeviceToken = deviceToken self.logger.debug("success registering the push token with Knock") + completion(.success(())) case .failure(let error): - self.logger.error("error in registerTokenForAPNS: \(error.localizedDescription)") + self.logger.error("Error in registerTokenForAPNS: \(error.localizedDescription)") + completion(.failure(error)) } } } @@ -31,21 +47,38 @@ public extension Knock { func isAuthenticated(checkUserToken: Bool = false) -> Bool { if checkUserToken { - return self.userId?.isEmpty == false && self.userToken?.isEmpty == false + return self.userId?.isEmpty == false && self.api.userToken?.isEmpty == false } return self.userId?.isEmpty == false } - func logout() { - self.userId = nil - self.userToken = nil + /** + Sets the user for the current Knock instance. Will also register the device for push notifications if token and channelId are provided. + You can also register the device for push notifications later on by calling Knock.registerTokenForAPNS() + */ + func logout(completion: @escaping (Result) -> Void) { + guard let channelId = self.pushChannelId, let token = self.userDeviceToken else { + self.resetInstance() + completion(.success(())) + return + } + self.unregisterTokenForAPNS(channelId: channelId, token: token) { result in + switch result { + case .success(_): + self.resetInstance() + completion(.success(())) + case .failure(let error): + // Don't reset data if there was an error in the unregistration step. That way the client can retry the logout if they want. + completion(.failure(error)) + } + } } func getUser(completionHandler: @escaping ((Result) -> Void)) { - self.api?.decodeFromGet(User.self, path: "/users/\(self.safeUserId)", queryItems: nil, then: completionHandler) + self.api.decodeFromGet(User.self, path: "/users/\(self.safeUserId)", queryItems: nil, then: completionHandler) } func updateUser(user: User, completionHandler: @escaping ((Result) -> Void)) { - self.api?.decodeFromPut(User.self, path: "/users/\(self.safeUserId)", body: user, then: completionHandler) + self.api.decodeFromPut(User.self, path: "/users/\(self.safeUserId)", body: user, then: completionHandler) } } From 4f149052b330500d93ef8ad01d2e48ae1d201a11 Mon Sep 17 00:00:00 2001 From: Matt Gardner Date: Thu, 25 Jan 2024 16:14:13 -0700 Subject: [PATCH 5/9] Added more safety around the userId checks --- Sources/Knock.swift | 27 ++++++++++++------- Sources/KnockAPI.swift | 6 +---- Sources/Modules/Channels/ChannelService.swift | 14 ++++++---- Sources/Modules/Feed/FeedManager.swift | 10 +++++-- .../Preferences/PreferenceService.swift | 14 +++++++--- Sources/Modules/Users/UserService.swift | 10 ++++--- 6 files changed, 53 insertions(+), 28 deletions(-) diff --git a/Sources/Knock.swift b/Sources/Knock.swift index 759ea4c..3c3276d 100644 --- a/Sources/Knock.swift +++ b/Sources/Knock.swift @@ -14,7 +14,7 @@ public class Knock { internal var api: KnockAPI - public var feedManager: FeedManager? + public internal(set) var feedManager: FeedManager? public internal(set) var userId: String? public internal(set) var pushChannelId: String? public internal(set) var userDeviceToken: String? @@ -37,15 +37,15 @@ public class Knock { self.feedManager = nil self.userDeviceToken = nil self.pushChannelId = nil - self.api.updateUserInfo(userToken: nil) + self.api.userToken = nil } - internal var safeUserId: String { - guard let id = userId else { - fatalError("[Knock] You must call Knock.shared.authenticate() first before trying to make a request where userId is required...") - } - return id - } +// internal var safeUserId: String { +// guard let id = userId else { +// fatalError("[Knock] You must call Knock.shared.authenticate() first before trying to make a request where userId is required...") +// } +// return id +// } } extension Knock { @@ -56,10 +56,19 @@ extension Knock { public enum KnockError: Error { case runtimeError(String) + case userIdError +// "UserId not found. Please authenticate your userId with Knock.authenticate()." + } + + func performActionWithUserId(_ action: @escaping (String, @escaping (Result) -> Void) -> Void, completionHandler: @escaping (Result) -> Void) { + guard let userId = self.userId else { + completionHandler(.failure(KnockError.userIdError)) + return + } + action(userId, completionHandler) } } // NoTES: // Should we provide more safety around userID being invalid? Instead of fatal erroring out the app. -// Ensure that switching api to struct is the right move. diff --git a/Sources/KnockAPI.swift b/Sources/KnockAPI.swift index be0668e..b78fdec 100644 --- a/Sources/KnockAPI.swift +++ b/Sources/KnockAPI.swift @@ -7,7 +7,7 @@ import Foundation -struct KnockAPI { +class KnockAPI { internal let publishableKey: String internal private(set) var host = "https://api.knock.app" public internal(set) var userToken: String? @@ -23,10 +23,6 @@ struct KnockAPI { self.publishableKey = publishableKey } - internal mutating func updateUserInfo(userToken: String?) { - self.userToken = userToken - } - // MARK: Decode functions, they encapsulate making the request and decoding the data internal func decodeFromGet(_ type: T.Type, path: String, queryItems: [URLQueryItem]?, then handler: @escaping (Result) -> Void) { diff --git a/Sources/Modules/Channels/ChannelService.swift b/Sources/Modules/Channels/ChannelService.swift index 96d955a..efe98c1 100644 --- a/Sources/Modules/Channels/ChannelService.swift +++ b/Sources/Modules/Channels/ChannelService.swift @@ -14,7 +14,9 @@ public extension Knock { } func getUserChannelData(channelId: String, completionHandler: @escaping ((Result) -> Void)) { - self.api.decodeFromGet(ChannelData.self, path: "/users/\(self.safeUserId)/channel_data/\(channelId)", queryItems: nil, then: completionHandler) + performActionWithUserId( { userId, completion in + self.api.decodeFromGet(ChannelData.self, path: "/users/\(userId)/channel_data/\(channelId)", queryItems: nil, then: completion) + }, completionHandler: completionHandler) } /** @@ -25,10 +27,12 @@ public extension Knock { - data: the shape of the payload varies depending on the channel. You can learn more about channel data schemas [here](https://docs.knock.app/send-notifications/setting-channel-data#provider-data-requirements). */ func updateUserChannelData(channelId: String, data: AnyEncodable, completionHandler: @escaping ((Result) -> Void)) { - let payload = [ - "data": data - ] - self.api.decodeFromPut(ChannelData.self, path: "/users/\(self.safeUserId)/channel_data/\(channelId)", body: payload, then: completionHandler) + performActionWithUserId( { userId, completion in + let payload = [ + "data": data + ] + self.api.decodeFromPut(ChannelData.self, path: "/users/\(userId)/channel_data/\(channelId)", body: payload, then: completion) + }, completionHandler: completionHandler) } // Mark: Registration of APNS device tokens diff --git a/Sources/Modules/Feed/FeedManager.swift b/Sources/Modules/Feed/FeedManager.swift index d72200f..5e94b45 100644 --- a/Sources/Modules/Feed/FeedManager.swift +++ b/Sources/Modules/Feed/FeedManager.swift @@ -10,8 +10,13 @@ import SwiftPhoenixClient import OSLog public extension Knock { - func initializeFeedManager(feedId: String, options: FeedManager.FeedClientOptions = FeedManager.FeedClientOptions(archived: .exclude)) { - self.feedManager = FeedManager(api: self.api, userId: self.safeUserId, feedId: feedId, options: options) + func initializeFeedManager(feedId: String, options: FeedManager.FeedClientOptions = FeedManager.FeedClientOptions(archived: .exclude)) throws { + guard let safeUserId = self.userId else { throw KnockError.userIdError } + self.feedManager = FeedManager(api: self.api, userId: safeUserId, feedId: feedId, options: options) + } + + func deInitializeFeedManager() { + self.feedManager = nil } class FeedManager { @@ -63,6 +68,7 @@ public extension Knock { } } + // TODO: Determine the level of logging we want from SwiftPhoenixClient. Currently this produces a lot of noise. // socket.logger = { msg in print("LOG:", msg) } let mergedOptions = defaultFeedOptions.mergeOptions(options: options) diff --git a/Sources/Modules/Preferences/PreferenceService.swift b/Sources/Modules/Preferences/PreferenceService.swift index b876d29..f2c431a 100644 --- a/Sources/Modules/Preferences/PreferenceService.swift +++ b/Sources/Modules/Preferences/PreferenceService.swift @@ -10,15 +10,21 @@ import Foundation public extension Knock { func getAllUserPreferences(completionHandler: @escaping ((Result<[PreferenceSet], Error>) -> Void)) { - self.api.decodeFromGet([PreferenceSet].self, path: "/users/\(self.safeUserId)/preferences", queryItems: nil, then: completionHandler) + performActionWithUserId( { userId, completion in + self.api.decodeFromGet([PreferenceSet].self, path: "/users/\(userId)/preferences", queryItems: nil, then: completion) + }, completionHandler: completionHandler) } func getUserPreferences(preferenceId: String, completionHandler: @escaping ((Result) -> Void)) { - self.api.decodeFromGet(PreferenceSet.self, path: "/users/\(self.safeUserId)/preferences/\(preferenceId)", queryItems: nil, then: completionHandler) + performActionWithUserId( { userId, completion in + self.api.decodeFromGet(PreferenceSet.self, path: "/users/\(userId)/preferences/\(preferenceId)", queryItems: nil, then: completion) + }, completionHandler: completionHandler) } func setUserPreferences(preferenceId: String, preferenceSet: PreferenceSet, completionHandler: @escaping ((Result) -> Void)) { - let payload = preferenceSet - self.api.decodeFromPut(PreferenceSet.self, path: "/users/\(self.safeUserId)/preferences/\(preferenceId)", body: payload, then: completionHandler) + performActionWithUserId( { userId, completion in + let payload = preferenceSet + self.api.decodeFromPut(PreferenceSet.self, path: "/users/\(userId)/preferences/\(preferenceId)", body: payload, then: completion) + }, completionHandler: completionHandler) } } diff --git a/Sources/Modules/Users/UserService.swift b/Sources/Modules/Users/UserService.swift index 38bf7a1..6889217 100644 --- a/Sources/Modules/Users/UserService.swift +++ b/Sources/Modules/Users/UserService.swift @@ -28,7 +28,7 @@ public extension Knock { self.userId = userId self.pushChannelId = pushChannelId self.userDeviceToken = deviceToken - self.api.updateUserInfo(userToken: userToken) + self.api.userToken = userToken if let token = deviceToken, let channelId = pushChannelId { self.registerTokenForAPNS(channelId: channelId, token: token) { result in switch result { @@ -75,10 +75,14 @@ public extension Knock { } func getUser(completionHandler: @escaping ((Result) -> Void)) { - self.api.decodeFromGet(User.self, path: "/users/\(self.safeUserId)", queryItems: nil, then: completionHandler) + performActionWithUserId( { userId, completion in + self.api.decodeFromGet(User.self, path: "/users/\(userId)", queryItems: nil, then: completion) + }, completionHandler: completionHandler) } func updateUser(user: User, completionHandler: @escaping ((Result) -> Void)) { - self.api.decodeFromPut(User.self, path: "/users/\(self.safeUserId)", body: user, then: completionHandler) + performActionWithUserId( { userId, completion in + self.api.decodeFromPut(User.self, path: "/users/\(userId)", body: user, then: completion) + }, completionHandler: completionHandler) } } From 133770fc70caa460be0429fe3f5519686b5b6246 Mon Sep 17 00:00:00 2001 From: Matt Gardner Date: Thu, 25 Jan 2024 16:22:26 -0700 Subject: [PATCH 6/9] Cleanup --- Sources/Helpers/Utilities.swift | 8 ++++++++ Sources/Knock.swift | 15 --------------- 2 files changed, 8 insertions(+), 15 deletions(-) diff --git a/Sources/Helpers/Utilities.swift b/Sources/Helpers/Utilities.swift index e8599a5..c40fc0d 100644 --- a/Sources/Helpers/Utilities.swift +++ b/Sources/Helpers/Utilities.swift @@ -33,6 +33,14 @@ internal extension Knock { } return tokenParts.joined() } + + func performActionWithUserId(_ action: @escaping (String, @escaping (Result) -> Void) -> Void, completionHandler: @escaping (Result) -> Void) { + guard let userId = self.userId else { + completionHandler(.failure(KnockError.userIdError)) + return + } + action(userId, completionHandler) + } } struct DynamicCodingKey: CodingKey { diff --git a/Sources/Knock.swift b/Sources/Knock.swift index 3c3276d..5566b0b 100644 --- a/Sources/Knock.swift +++ b/Sources/Knock.swift @@ -39,13 +39,6 @@ public class Knock { self.pushChannelId = nil self.api.userToken = nil } - -// internal var safeUserId: String { -// guard let id = userId else { -// fatalError("[Knock] You must call Knock.shared.authenticate() first before trying to make a request where userId is required...") -// } -// return id -// } } extension Knock { @@ -59,14 +52,6 @@ extension Knock { case userIdError // "UserId not found. Please authenticate your userId with Knock.authenticate()." } - - func performActionWithUserId(_ action: @escaping (String, @escaping (Result) -> Void) -> Void, completionHandler: @escaping (Result) -> Void) { - guard let userId = self.userId else { - completionHandler(.failure(KnockError.userIdError)) - return - } - action(userId, completionHandler) - } } From fbc7ff4b549ebfbf79a71d9129d5761d5e101289 Mon Sep 17 00:00:00 2001 From: Matt Gardner Date: Thu, 25 Jan 2024 16:47:37 -0700 Subject: [PATCH 7/9] Make KnockError a LocalizedError --- Sources/Knock.swift | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/Sources/Knock.swift b/Sources/Knock.swift index 5566b0b..a6e4780 100644 --- a/Sources/Knock.swift +++ b/Sources/Knock.swift @@ -45,15 +45,25 @@ extension Knock { // Configuration options for the Knock client SDK. public struct KnockOptions { var host: String? + + init(host: String? = nil) { + self.host = host + } } public enum KnockError: Error { case runtimeError(String) case userIdError -// "UserId not found. Please authenticate your userId with Knock.authenticate()." } } - -// NoTES: -// Should we provide more safety around userID being invalid? Instead of fatal erroring out the app. +extension Knock.KnockError: LocalizedError { + public var errorDescription: String? { + switch self { + case .runtimeError(let message): + return message + case .userIdError: + return "UserId not found. Please authenticate your userId with Knock.authenticate()." + } + } +} From 30a58b5d8dfd6e4aa2b6aa417fc396bf3ed2368f Mon Sep 17 00:00:00 2001 From: Matt Gardner Date: Thu, 25 Jan 2024 16:54:22 -0700 Subject: [PATCH 8/9] Updated KnockOptions init --- Sources/Knock.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Sources/Knock.swift b/Sources/Knock.swift index a6e4780..3880670 100644 --- a/Sources/Knock.swift +++ b/Sources/Knock.swift @@ -41,17 +41,17 @@ public class Knock { } } -extension Knock { +public extension Knock { // Configuration options for the Knock client SDK. - public struct KnockOptions { + struct KnockOptions { var host: String? - init(host: String? = nil) { + public init(host: String? = nil) { self.host = host } } - public enum KnockError: Error { + enum KnockError: Error { case runtimeError(String) case userIdError } From 0eddb3522484b625d4ccc962d3bfe99ff0c00959 Mon Sep 17 00:00:00 2001 From: Matt Gardner Date: Thu, 25 Jan 2024 17:00:01 -0700 Subject: [PATCH 9/9] updates --- Sources/Knock.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Knock.swift b/Sources/Knock.swift index 3880670..f28a599 100644 --- a/Sources/Knock.swift +++ b/Sources/Knock.swift @@ -28,7 +28,7 @@ public class Knock { - userToken: [optional] user token. Used in production when enhanced security is enabled - options: [optional] Options for customizing the Knock instance. */ - public init(publishableKey: String, options: KnockOptions?) { + public init(publishableKey: String, options: KnockOptions? = nil) { self.api = KnockAPI(publishableKey: publishableKey, hostname: options?.host) }