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