diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/Knock.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/Knock.xcscheme index 19db893..f5f8b6e 100644 --- a/.swiftpm/xcode/xcshareddata/xcschemes/Knock.xcscheme +++ b/.swiftpm/xcode/xcshareddata/xcschemes/Knock.xcscheme @@ -28,6 +28,18 @@ selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv = "YES" shouldAutocreateTestPlan = "YES"> + + + + + + (_ action: @escaping (String, @escaping (Result) -> Void) -> Void, completionHandler: @escaping (Result) -> Void) { - guard let userId = self.userId else { - completionHandler(.failure(KnockError.userIdError)) - return - } - action(userId, completionHandler) - } } struct DynamicCodingKey: CodingKey { diff --git a/Sources/Knock.swift b/Sources/Knock.swift index f28a599..6ebbbf1 100644 --- a/Sources/Knock.swift +++ b/Sources/Knock.swift @@ -6,64 +6,66 @@ // import SwiftUI +import OSLog // Knock client SDK. public class Knock { internal static let clientVersion = "1.0.0" - internal static let loggingSubsytem = "knock-swift" - internal var api: KnockAPI + public static var shared: Knock = Knock() + + public var feedManager: FeedManager? + + internal let environment = KnockEnvironment() + internal lazy var authenticationModule = AuthenticationModule() + internal lazy var userModule = UserModule() + internal lazy var preferenceModule = PreferenceModule() + internal lazy var messageModule = MessageModule() + internal lazy var channelModule = ChannelModule() + internal lazy var logger = KnockLogger() - public internal(set) var feedManager: FeedManager? - public internal(set) var userId: String? - public internal(set) var pushChannelId: String? - public internal(set) var userDeviceToken: String? - /** Returns a new instance of the Knock Client - Parameters: - publishableKey: your public API key - - userId: the user-id that will be used in the subsequent method calls - - userToken: [optional] user token. Used in production when enhanced security is enabled - options: [optional] Options for customizing the Knock instance. */ - public init(publishableKey: String, options: KnockOptions? = nil) { - self.api = KnockAPI(publishableKey: publishableKey, hostname: options?.host) + public func setup(publishableKey: String, pushChannelId: String?, options: Knock.KnockStartupOptions? = nil) throws { + logger.loggingDebugOptions = options?.debuggingType ?? .errorsOnly + try environment.setPublishableKey(key: publishableKey) + environment.setBaseUrl(baseUrl: options?.hostname) + environment.pushChannelId = pushChannelId } - internal func resetInstance() { - self.userId = nil - self.feedManager = nil - self.userDeviceToken = nil - self.pushChannelId = nil - self.api.userToken = nil + public func resetInstanceCompletely() { + Knock.shared = Knock() } } public extension Knock { - // Configuration options for the Knock client SDK. - struct KnockOptions { - var host: String? - - public init(host: String? = nil) { - self.host = host + struct KnockStartupOptions { + public init(hostname: String? = nil, debuggingType: DebugOptions = .errorsOnly) { + self.hostname = hostname + self.debuggingType = debuggingType } + var hostname: String? + var debuggingType: DebugOptions } - enum KnockError: Error { - case runtimeError(String) - case userIdError + enum DebugOptions { + case errorsOnly + case verbose + case none } } -extension Knock.KnockError: LocalizedError { - public var errorDescription: String? { - switch self { - case .runtimeError(let message): - return message - case .userIdError: - return "UserId not found. Please authenticate your userId with Knock.authenticate()." - } +public extension Knock { + var userId: String? { + get { return environment.userId } + } + + var apnsDeviceToken: String? { + get { return environment.userId } } } diff --git a/Sources/KnockAPI.swift b/Sources/KnockAPI.swift deleted file mode 100644 index b78fdec..0000000 --- a/Sources/KnockAPI.swift +++ /dev/null @@ -1,205 +0,0 @@ -// -// Net.swift -// KnockSample -// -// Created by Diego on 26/04/23. -// - -import Foundation - -class KnockAPI { - internal let publishableKey: String - internal private(set) var host = "https://api.knock.app" - public internal(set) var userToken: String? - - private var apiBasePath: String { - "\(host)/v1" - } - - internal init(publishableKey: String, hostname: String? = nil) { - if let customHostname = hostname { - self.host = customHostname - } - self.publishableKey = publishableKey - } - - // MARK: Decode functions, they encapsulate making the request and decoding the data - - internal func decodeFromGet(_ type: T.Type, path: String, queryItems: [URLQueryItem]?, then handler: @escaping (Result) -> Void) { - get(path: path, queryItems: queryItems) { (result) in - self.decodeData(result, handler: handler) - } - } - - internal func decodeFromPost(_ type: T.Type, path: String, body: Encodable?, then handler: @escaping (Result) -> Void) { - post(path: path, body: body) { (result) in - self.decodeData(result, handler: handler) - } - } - - internal func decodeFromPut(_ type: T.Type, path: String, body: Encodable?, then handler: @escaping (Result) -> Void) { - put(path: path, body: body) { (result) in - self.decodeData(result, handler: handler) - } - } - - internal func decodeFromDelete(_ type: T.Type, path: String, body: Encodable?, then handler: @escaping (Result) -> Void) { - delete(path: path, body: body) { (result) in - self.decodeData(result, handler: handler) - } - } - - internal func decodeData(_ result: Result, handler: @escaping (Result) -> Void) { - switch result { - case .success(let data): - let decoder = JSONDecoder() - - let formatter = DateFormatter() - formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSZZZZZ" - formatter.calendar = Calendar(identifier: .iso8601) - formatter.timeZone = TimeZone(secondsFromGMT: 0) - formatter.locale = Locale(identifier: "en_US_POSIX") - - decoder.dateDecodingStrategy = .formatted(formatter) - - do { - let resultType = try decoder.decode(T.self, from: data) - DispatchQueue.main.async { - handler(.success(resultType)) - } - } - catch let error { - if let dataString = String(data: data, encoding: .utf8) { - print("error decoding data: \(dataString)") - } - else { - print("error processing undecodable data") - } - - DispatchQueue.main.async { - handler(.failure(error)) - } - } - case .failure(let error): - DispatchQueue.main.async { - handler(.failure(error)) - } - } - } - - // MARK: Method wrappers to adjust where to put the parameters (query or body) - - private func get(path: String, queryItems: [URLQueryItem]?, then handler: @escaping (Result) -> Void) { - makeGeneralRequest(method: "GET", path: path, queryItems: queryItems, body: nil, then: handler) - } - - private func post(path: String, body: Encodable?, then handler: @escaping (Result) -> Void) { - makeGeneralRequest(method: "POST", path: path, queryItems: nil, body: body, then: handler) - } - - private func put(path: String, body: Encodable?, then handler: @escaping (Result) -> Void) { - makeGeneralRequest(method: "PUT", path: path, queryItems: nil, body: body, then: handler) - } - - private func delete(path: String, body: Encodable?, then handler: @escaping (Result) -> Void) { - makeGeneralRequest(method: "DELETE", path: path, queryItems: nil, body: body, then: handler) - } - - // MARK: Actual code to make the request - - /** - Make the http request. The completion `then` handler is executed asyncronously on the main thread. - - - Attention: Currently, there's no retry policy. Here would be a good plate to implement it, for instance X number of retries and an exponential timeout on each one. Also, first check if the path or request is allowed to be retried (is it safe to retry or does it support an idempotency token?) - - - Parameters: - - method: GET, POST, PUT, DELETE - - path: the absolute path (host [+ port] + relative path) - - queryItems: optional array of `URLQueryItem` - - body: optional array of parameters to pass in the body of the request - - then: the code to execute when the response is received - */ - private func makeGeneralRequest(method: String, path: String, queryItems: [URLQueryItem]?, body: Encodable?, then handler: @escaping (Result) -> Void) { - - let sessionConfig = URLSessionConfiguration.default - let session = URLSession(configuration: sessionConfig, delegate: nil, delegateQueue: nil) - guard var URL = URL(string: "\(apiBasePath)\(path)") else {return} - - if queryItems != nil { - URL = URL.appending(queryItems: queryItems!) - } - - var request = URLRequest(url: URL) - request.httpMethod = method - - request.addValue("application/json; charset=utf-8", forHTTPHeaderField: "Content-Type") - - if body != nil { - let encoder = JSONEncoder() - let data = try! encoder.encode(body!) - request.httpBody = data - } - - // Headers - - request.addValue("knock-swift@\(Knock.clientVersion)", forHTTPHeaderField: "User-Agent") - - request.addValue("Bearer \(publishableKey)", forHTTPHeaderField: "Authorization") - if let userToken = userToken { - request.addValue(userToken, forHTTPHeaderField: "X-Knock-User-Token") - } - - // Make the request - - let task = session.dataTask(with: request, completionHandler: { (data: Data?, response: URLResponse?, error: Error?) -> Void in - if (error == nil) { - // Success - let statusCode = (response as! HTTPURLResponse).statusCode - - if let data = data { - if statusCode < 200 || statusCode > 299 { - DispatchQueue.main.async { - handler(.failure(NetworkError(title: "Status code error", description: String(data: data, encoding: .utf8) ?? "Unknown error", code: statusCode))) - } - } - - DispatchQueue.main.async { - handler(.success(data)) - } - } - else { - DispatchQueue.main.async { - handler(.failure(NetworkError(title: "Unknown Error", description: "Error, data == nil", code: statusCode))) - } - } - } - else { - DispatchQueue.main.async { - handler(.failure(error!)) - } - } - }) - task.resume() - session.finishTasksAndInvalidate() - } -} - -public protocol NetworkErrorProtocol: LocalizedError { - var title: String? { get } - var code: Int { get } -} - -public struct NetworkError: NetworkErrorProtocol { - public var title: String? - public var code: Int - public var errorDescription: String? { return _description } - public var failureReason: String? { return _description } - - private var _description: String - - public init(title: String?, description: String, code: Int) { - self.title = title ?? "Error" - self._description = description - self.code = code - } -} diff --git a/Sources/KnockAPIService.swift b/Sources/KnockAPIService.swift new file mode 100644 index 0000000..6ba2f8f --- /dev/null +++ b/Sources/KnockAPIService.swift @@ -0,0 +1,114 @@ +// +// KnockAPIService.swift +// KnockSample +// +// Created by Matt on 01/29/2023. +// + +import Foundation +import OSLog + +internal protocol KnockAPIService { + func get(path: String, queryItems: [URLQueryItem]?) async throws -> T + func put(path: String, body: Encodable?) async throws -> T + func post(path: String, body: Encodable?) async throws -> T + func delete(path: String, body: Encodable?) async throws -> T + func makeRequest(method: String, path: String, queryItems: [URLQueryItem]?, body: Encodable?) async throws -> T +} + +extension KnockAPIService { + private var apiBaseUrl: String { + return "\(Knock.shared.environment.baseUrl)/v1" + } + + func makeRequest(method: String, path: String, queryItems: [URLQueryItem]?, body: Encodable?) async throws -> T { + let sessionConfig = URLSessionConfiguration.default + let session = URLSession(configuration: sessionConfig, delegate: nil, delegateQueue: nil) + + let loggingMessageSummary = "\(method) \(apiBaseUrl)\(path)" + + guard var URL = URL(string: "\(apiBaseUrl)\(path)") else { + let networkError = Knock.NetworkError(title: "Invalid URL", description: "The URL: \(apiBaseUrl)\(path) is invalid", code: 0) + Knock.shared.log(type: .warning, category: .networking, message: loggingMessageSummary, status: .fail, errorMessage: networkError.localizedDescription) + throw networkError + } + + if queryItems != nil { + URL = URL.appending(queryItems: queryItems!) + } + + var request = URLRequest(url: URL) + request.httpMethod = method + + request.addValue("application/json; charset=utf-8", forHTTPHeaderField: "Content-Type") + + if body != nil { + let encoder = JSONEncoder() + let data = try! encoder.encode(body!) + request.httpBody = data + } + + // Headers + + request.addValue("knock-swift@\(Knock.clientVersion)", forHTTPHeaderField: "User-Agent") + + request.addValue("Bearer \(try Knock.shared.environment.getSafePublishableKey())", forHTTPHeaderField: "Authorization") + if let userToken = Knock.shared.environment.userToken { + request.addValue(userToken, forHTTPHeaderField: "X-Knock-User-Token") + } + + // Make the request + let (responseData, urlResponse) = try await session.data(for: request) + let statusCode = (urlResponse as! HTTPURLResponse).statusCode + if statusCode < 200 || statusCode > 299 { + let networkError = Knock.NetworkError(title: "Status code error", description: String(data: responseData, encoding: .utf8) ?? "Unknown error", code: statusCode) + Knock.shared.log(type: .warning, category: .networking, message: loggingMessageSummary, status: .fail, errorMessage: networkError.localizedDescription) + throw networkError + } else { + Knock.shared.log(type: .debug, category: .networking, message: loggingMessageSummary, status: .success) + } + + return try decodeData(responseData) + } + internal func get(path: String, queryItems: [URLQueryItem]?) async throws -> T { + try await makeRequest(method: "GET", path: path, queryItems: queryItems, body: nil) + } + + internal func post(path: String, body: Encodable?) async throws -> T { + try await makeRequest(method: "POST", path: path, queryItems: nil, body: body) + } + + internal func put(path: String, body: Encodable?) async throws -> T { + try await makeRequest(method: "PUT", path: path, queryItems: nil, body: body) + } + + internal func delete(path: String, body: Encodable?) async throws -> T { + try await makeRequest(method: "DELETE", path: path, queryItems: nil, body: body) + } + + internal func decodeData(_ data: Data) throws -> T { + let decoder = JSONDecoder() + + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSZZZZZ" + formatter.calendar = Calendar(identifier: .iso8601) + formatter.timeZone = TimeZone(secondsFromGMT: 0) + formatter.locale = Locale(identifier: "en_US_POSIX") + + decoder.dateDecodingStrategy = .formatted(formatter) + + do { + let result = try decoder.decode(T.self, from: data) + return result + } catch let error { + if let dataString = String(data: data, encoding: .utf8) { + let decodeError = Knock.KnockError.runtimeError("Error decoding data: \(dataString)") + Knock.shared.log(type: .error, category: .networking, message: "Error decoding data: \(dataString)", status: .fail, errorMessage: decodeError.localizedDescription) + throw decodeError + } else { + Knock.shared.log(type: .error, category: .networking, message: "Error processing undecodable data", status: .fail, errorMessage: error.localizedDescription) + throw error + } + } + } +} diff --git a/Sources/KnockAppDelegate.swift b/Sources/KnockAppDelegate.swift index 6b68def..47a3df4 100644 --- a/Sources/KnockAppDelegate.swift +++ b/Sources/KnockAppDelegate.swift @@ -11,80 +11,79 @@ import OSLog @available(iOSApplicationExtension, unavailable) open class KnockAppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterDelegate { - - private let logger = Logger(subsystem: "knock-swift", category: "KnockAppDelegate") - + // MARK: Init - override init() { + public override init() { super.init() - - // Register to ensure device token can be fetched UIApplication.shared.registerForRemoteNotifications() UNUserNotificationCenter.current().delegate = self - } // MARK: Launching open func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool { + // Check if launched from a notification + if let launchOptions = launchOptions, + let userInfo = launchOptions[.remoteNotification] as? [String: AnyObject] { + Knock.shared.log(type: .error, category: .pushNotification, message: "pushNotificationTapped") + pushNotificationTapped(userInfo: userInfo) + } + return true } - // MARK: Notifications + // MARK: Token Management - open func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { - logger.debug("userNotificationCenter willPresent notification: \(notification)") - - let userInfo = notification.request.content.userInfo - - let presentationOptions = pushNotificationDeliveredInForeground(userInfo: userInfo) - completionHandler(presentationOptions) + open func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) { + Knock.shared.log(type: .error, category: .pushNotification, message: "Failed to register for notifications", errorMessage: error.localizedDescription) } - open func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { - logger.debug("didReceiveNotificationResponse: \(response)") - - let userInfo = response.notification.request.content.userInfo - - if response.actionIdentifier == UNNotificationDismissActionIdentifier { - pushNotificationDismissed(userInfo: userInfo) - } else { - pushNotificationTapped(userInfo: userInfo) + open func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { + Task { + let channelId = Knock.shared.environment.pushChannelId + + do { + if let id = channelId { + let _ = try await Knock.shared.registerTokenForAPNS(channelId: id, token: deviceToken) + } else { + Knock.shared.log(type: .error, category: .pushNotification, message: "didRegisterForRemoteNotificationsWithDeviceToken", status: .fail, errorMessage: "Unable to find pushChannelId. Please set the pushChannelId with Knock.shared.setup") + } + } catch let error { + Knock.shared.log(type: .error, category: .pushNotification, message: "didRegisterForRemoteNotificationsWithDeviceToken", description: "Unable to register for push notification at this time", status: .fail, errorMessage: error.localizedDescription) + } } - completionHandler() } - // MARK: Token Management + // MARK: Notifications - open func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) { - logger.error("Failed to register for notifications: \(error.localizedDescription)") + open func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { + Knock.shared.log(type: .debug, category: .pushNotification, message: "pushNotificationDeliveredInForeground") + let presentationOptions = pushNotificationDeliveredInForeground(notification: notification) + completionHandler(presentationOptions) } - open func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { - logger.debug("Successfully registered for notifications!") - - // 1. Convert device token to string - let tokenParts = deviceToken.map { data -> String in - return String(format: "%02.2hhx", data) - } - let token = tokenParts.joined() - // 2. Print device token to use for PNs payloads - logger.debug("Device Token: \(token)") - - let defaults = UserDefaults.standard - defaults.set(token, forKey: "device_push_token") -// deviceTokenDidChange(apnsToken: token, isDebugging: isDebuggerAttached) -// self.pushToken = token + open func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { + Knock.shared.log(type: .debug, category: .pushNotification, message: "pushNotificationTapped") + pushNotificationTapped(userInfo: response.notification.request.content.userInfo) + completionHandler() } - // MARK: Functions + open func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable : Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) { + Knock.shared.log(type: .debug, category: .pushNotification, message: "pushNotificationDeliveredSilently") + pushNotificationDeliveredSilently(userInfo: userInfo, completionHandler: completionHandler) + } - open func deviceTokenDidChange(apnsToken: String, isDebugging: Bool) {} + // MARK: Convenience methods to make handling incoming push notifications simpler. + open func deviceTokenDidChange(apnsToken: String) {} - open func pushNotificationDeliveredInForeground(userInfo: [AnyHashable : Any]) -> UNNotificationPresentationOptions { return [] } + open func pushNotificationDeliveredInForeground(notification: UNNotification) -> UNNotificationPresentationOptions { + return [.sound, .badge, .banner] + } open func pushNotificationTapped(userInfo: [AnyHashable : Any]) {} - - open func pushNotificationDismissed(userInfo: [AnyHashable : Any]) {} + + open func pushNotificationDeliveredSilently(userInfo: [AnyHashable : Any], completionHandler: @escaping (UIBackgroundFetchResult) -> Void) { + completionHandler(.noData) + } } diff --git a/Sources/KnockEnvironment.swift b/Sources/KnockEnvironment.swift new file mode 100644 index 0000000..dbdd0bc --- /dev/null +++ b/Sources/KnockEnvironment.swift @@ -0,0 +1,70 @@ +// +// KnockEnvironment.swift +// +// +// Created by Matt Gardner on 1/29/24. +// + +import Foundation + +internal class KnockEnvironment { + static let defaultBaseUrl: String = "https://api.knock.app" + private let defaults = UserDefaults.standard + private let userDevicePushTokenKey = "knock_push_device_token" + private let pushChannelIdKey = "knock_push_channel_id" + + private(set) var userId: String? + private(set) var userToken: String? + private(set) var publishableKey: String? + private(set) var baseUrl: String = defaultBaseUrl + + var userDevicePushToken: String? { + get { + defaults.string(forKey: userDevicePushTokenKey) + } + set { + defaults.set(newValue, forKey: userDevicePushTokenKey) + } + } + + var pushChannelId: String? { + get { + defaults.string(forKey: pushChannelIdKey) + } + set { + defaults.set(newValue, forKey: pushChannelIdKey) + } + } + + func setPublishableKey(key: String) throws { + guard key.hasPrefix("sk_") == false else { + let error = Knock.KnockError.wrongKeyError + Knock.shared.log(type: .error, category: .general, message: "setPublishableKey", status: .fail, errorMessage: error.localizedDescription) + throw error + } + self.publishableKey = key + } + + func setUserInfo(userId: String?, userToken: String?) { + self.userId = userId + self.userToken = userToken + } + + func setBaseUrl(baseUrl: String?) { + self.baseUrl = "\(baseUrl ?? KnockEnvironment.defaultBaseUrl)" + } + + func getSafeUserId() throws -> String { + guard let id = userId else { + throw Knock.KnockError.userIdNotSetError + } + return id + } + + func getSafePublishableKey() throws -> String { + guard let id = publishableKey else { + throw Knock.KnockError.knockNotSetup + } + return id + } +} diff --git a/Sources/KnockErrors.swift b/Sources/KnockErrors.swift new file mode 100644 index 0000000..cb06e1b --- /dev/null +++ b/Sources/KnockErrors.swift @@ -0,0 +1,52 @@ +// +// KnockErrors.swift +// +// +// Created by Matt Gardner on 1/29/24. +// + +import Foundation + +public extension Knock { + enum KnockError: Error, Equatable { + case runtimeError(String) + case userIdNotSetError + case knockNotSetup + case wrongKeyError + } + + struct NetworkError: NetworkErrorProtocol { + public var title: String? + public var code: Int + public var errorDescription: String? { return _description } + public var failureReason: String? { return _description } + + private var _description: String + + public init(title: String?, description: String, code: Int) { + self.title = title ?? "Error" + self._description = description + self.code = code + } + } +} + +extension Knock.KnockError: LocalizedError { + public var errorDescription: String? { + switch self { + case .runtimeError(let message): + return message + case .userIdNotSetError: + return "UserId not found. Please authenticate your userId with Knock.signIn()." + case .knockNotSetup: + return "Knock instance still needs to be setup. Please setup with Knock.shared.setup()." + case .wrongKeyError: + return "You are using your secret API key on the client. Please use the public key." + } + } +} + +protocol NetworkErrorProtocol: LocalizedError { + var title: String? { get } + var code: Int { get } +} diff --git a/Sources/KnockLogger.swift b/Sources/KnockLogger.swift new file mode 100644 index 0000000..5f5ea72 --- /dev/null +++ b/Sources/KnockLogger.swift @@ -0,0 +1,91 @@ +// +// KnockLogger.swift +// +// +// Created by Matt Gardner on 1/30/24. +// + +import Foundation +import os.log + +internal class KnockLogger { + private static let loggingSubsytem = "knock-swift" + + internal var loggingDebugOptions: Knock.DebugOptions = .errorsOnly + + internal func log(type: LogType, category: LogCategory, message: String, description: String? = nil, status: LogStatus? = nil, errorMessage: String? = nil, additionalInfo: [String: String]? = nil) { + switch loggingDebugOptions { + case .errorsOnly: + if type != .error { + return + } + case .verbose: + break + case .none: + return + } + + var composedMessage = "[Knock] " + composedMessage += message + if let description = description { + composedMessage += " | description: \(description)" + } + if let status = status { + composedMessage += " | Status: \(status.rawValue)" + } + if let errorMessage = errorMessage { + composedMessage += " | Error: \(errorMessage)" + } + if let info = additionalInfo { + for (key, value) in info { + composedMessage += " | \(key): \(value)" + } + } + + // Use the Logger API for logging + let logger = Logger(subsystem: KnockLogger.loggingSubsytem, category: category.rawValue.capitalized) + switch type { + case .debug: + logger.debug("\(composedMessage)") + case .info: + logger.info("\(composedMessage)") + case .error: + logger.error("\(composedMessage)") + case .warning: + logger.warning("\(composedMessage)") + default: + logger.log("\(composedMessage)") + } + } + + internal enum LogStatus: String { + case success + case fail + } + + internal enum LogType { + case debug + case info + case error + case warning + case log + } + + internal enum LogCategory: String { + case user + case feed + case channel + case preferences + case networking + case pushNotification + case message + case general + case appDelegate + } +} + +extension Knock { + internal func log(type: KnockLogger.LogType, category: KnockLogger.LogCategory, message: String, description: String? = nil, status: KnockLogger.LogStatus? = nil, errorMessage: String? = nil, additionalInfo: [String: String]? = nil) { + Knock.shared.logger.log(type: type, category: category, message: message, description: description, status: status, errorMessage: errorMessage, additionalInfo: additionalInfo) + } +} diff --git a/Sources/Modules/Authentication/AuthenticationModule.swift b/Sources/Modules/Authentication/AuthenticationModule.swift new file mode 100644 index 0000000..3f922a6 --- /dev/null +++ b/Sources/Modules/Authentication/AuthenticationModule.swift @@ -0,0 +1,87 @@ +// +// AuthenticationModule.swift +// +// +// Created by Matt Gardner on 1/30/24. +// + +import Foundation + +internal class AuthenticationModule { + + func signIn(userId: String, userToken: String?) async { + Knock.shared.environment.setUserInfo(userId: userId, userToken: userToken) + + if let token = Knock.shared.environment.userDevicePushToken, let channelId = Knock.shared.environment.pushChannelId { + do { + let _ = try await Knock.shared.channelModule.registerTokenForAPNS(channelId: channelId, token: token) + } catch { + Knock.shared.logger.log(type: .warning, category: .user, message: "signIn", description: "Successfully set user, however, unable to registerTokenForAPNS as this time.") + } + } + + return + } + + func signOut() async throws { + guard let channelId = Knock.shared.environment.pushChannelId, let token = Knock.shared.environment.userDevicePushToken else { + clearDataForSignOut() + return + } + + let _ = try await Knock.shared.channelModule.unregisterTokenForAPNS(channelId: channelId, token: token) + clearDataForSignOut() + return + } + + func clearDataForSignOut() { + Knock.shared.environment.setUserInfo(userId: nil, userToken: nil) + } +} + +public extension Knock { + + func isAuthenticated(checkUserToken: Bool = false) -> Bool { + let isUser = Knock.shared.environment.userId?.isEmpty == false + if checkUserToken { + return isUser && Knock.shared.environment.userToken?.isEmpty == false + } + return isUser + } + + /** + Set the current credentials for the user and their access token. + Will also registerAPNS device token if set previously. + You should consider using this in areas where you update your local user's state + */ + func signIn(userId: String, userToken: String?) async { + await authenticationModule.signIn(userId: userId, userToken: userToken) + } + + func signIn(userId: String, userToken: String?, completionHandler: @escaping (() -> Void)) { + Task { + await signIn(userId: userId, userToken: userToken) + completionHandler() + } + } + + /** + Clears the current user id and access token + You should call this when your user signs out + It will remove the current tokens used for this user in Courier so they do not receive pushes they should not get + */ + func signOut() async throws { + try await authenticationModule.signOut() + } + + func signOut(completionHandler: @escaping ((Result) -> Void)) { + Task { + do { + try await signOut() + completionHandler(.success(())) + } catch { + completionHandler(.failure(error)) + } + } + } +} diff --git a/Sources/Modules/Channels/ChannelModule.swift b/Sources/Modules/Channels/ChannelModule.swift new file mode 100644 index 0000000..917a026 --- /dev/null +++ b/Sources/Modules/Channels/ChannelModule.swift @@ -0,0 +1,269 @@ +// +// ChannelModule.swift +// +// +// Created by Matt Gardner on 1/26/24. +// + +import Foundation +import OSLog +import UIKit + +internal class ChannelModule { + let channelService = ChannelService() + + internal var userNotificationCenter: UNUserNotificationCenter { + get { UNUserNotificationCenter.current() } + } + + func getUserChannelData(channelId: String) async throws -> Knock.ChannelData { + do { + let data = try await channelService.getUserChannelData(userId: Knock.shared.environment.getSafeUserId(), channelId: channelId) + Knock.shared.log(type: .debug, category: .channel, message: "getUserChannelData", status: .success) + return data + } catch let error { + Knock.shared.log(type: .warning, category: .channel, message: "getUserChannelData", status: .fail, errorMessage: error.localizedDescription) + throw error + } + } + + func updateUserChannelData(channelId: String, data: AnyEncodable) async throws -> Knock.ChannelData { + do { + let data = try await channelService.updateUserChannelData(userId: Knock.shared.environment.getSafeUserId(), channelId: channelId, data: data) + Knock.shared.log(type: .debug, category: .channel, message: "updateUserChannelData", status: .success) + return data + } catch let error { + Knock.shared.log(type: .warning, category: .channel, message: "updateUserChannelData", status: .fail, errorMessage: error.localizedDescription) + throw error + } + } + + private func registerOrUpdateToken(token: String, channelId: String, existingTokens: [String]?) async throws -> Knock.ChannelData { + var tokens = existingTokens ?? [] + if !tokens.contains(token) { + tokens.append(token) + } + + let data: AnyEncodable = ["tokens": tokens] + let channelData = try await updateUserChannelData(channelId: channelId, data: data) + Knock.shared.log(type: .debug, category: .pushNotification, message: "registerOrUpdateToken", status: .success) + return channelData + } + + func registerTokenForAPNS(channelId: String, token: String) async throws -> Knock.ChannelData { + Knock.shared.environment.pushChannelId = channelId + Knock.shared.environment.userDevicePushToken = token + + do { + let channelData = try await getUserChannelData(channelId: channelId) + guard let data = channelData.data, let tokens = data["tokens"]?.value as? [String] else { + // No valid tokens array found, register a new one + return try await registerOrUpdateToken(token: token, channelId: channelId, existingTokens: nil) + } + + if tokens.contains(token) { + // Token already registered + Knock.shared.log(type: .debug, category: .pushNotification, message: "registerTokenForAPNS", status: .success) + return channelData + } else { + // Register the new token + return try await registerOrUpdateToken(token: token, channelId: channelId, existingTokens: tokens) + } + } catch let userIdError as Knock.KnockError where userIdError == Knock.KnockError.userIdNotSetError { + Knock.shared.log(type: .warning, category: .pushNotification, message: "ChannelId and deviceToken were saved. However, we cannot register for APNS until you have have called Knock.signIn().") + throw userIdError + } catch { + // No data registered on that channel for that user, we'll create a new record + return try await registerOrUpdateToken(token: token, channelId: channelId, existingTokens: nil) + } + } + + func unregisterTokenForAPNS(channelId: String, token: String) async throws -> Knock.ChannelData { + do { + let channelData = try await getUserChannelData(channelId: channelId) + guard let data = channelData.data, let tokens = data["tokens"]?.value as? [String] else { + // No valid tokens array found. + Knock.shared.log(type: .warning, category: .pushNotification, message: "unregisterTokenForAPNS", description: "Could not unregister user from channel \(channelId). Reason: User doesn't have any device tokens associated to the provided channelId.") + return channelData + } + + if tokens.contains(token) { + let newTokensSet = Set(tokens).subtracting([token]) + let newTokens = Array(newTokensSet) + let data: AnyEncodable = [ + "tokens": newTokens + ] + let updateData = try await updateUserChannelData(channelId: channelId, data: data) + Knock.shared.log(type: .debug, category: .pushNotification, message: "unregisterTokenForAPNS", status: .success) + return updateData + } else { + Knock.shared.log(type: .warning, category: .pushNotification, message: "unregisterTokenForAPNS", description: "Could not unregister user from channel \(channelId). Reason: User doesn't have any device tokens associated to the provided channelId.") + return channelData + } + } catch { + if let networkError = error as? Knock.NetworkError, networkError.code == 404 { + // No data registered on that channel for that user + Knock.shared.log(type: .warning, category: .pushNotification, message: "unregisterTokenForAPNS", description: "Could not unregister user from channel \(channelId). Reason: User doesn't have any channel data associated to the provided channelId.") + return .init(channel_id: channelId, data: [:]) + } else { + // Unknown error. Could be network or server related. Try again. + Knock.shared.log(type: .error, category: .pushNotification, message: "unregisterTokenForAPNS", description: "Could not unregister user from channel \(channelId)", status: .fail, errorMessage: error.localizedDescription) + throw error + } + } + } +} + +public extension Knock { + + func getUserChannelData(channelId: String) async throws -> ChannelData { + try await self.channelModule.getUserChannelData(channelId: channelId) + } + + func getUserChannelData(channelId: String, completionHandler: @escaping ((Result) -> Void)) { + Task { + do { + let channelData = try await getUserChannelData(channelId: channelId) + completionHandler(.success(channelData)) + } catch { + completionHandler(.failure(error)) + } + } + } + + /** + Sets channel data for the user and the channel specified. + + - Parameters: + - channelId: the id of the channel + - data: the shape of the payload varies depending on the channel. You can learn more about channel data schemas [here](https://docs.knock.app/send-notifications/setting-channel-data#provider-data-requirements). + */ + func updateUserChannelData(channelId: String, data: AnyEncodable) async throws -> ChannelData { + try await self.channelModule.updateUserChannelData(channelId: channelId, data: data) + } + + func updateUserChannelData(channelId: String, data: AnyEncodable, completionHandler: @escaping ((Result) -> Void)) { + Task { + do { + let channelData = try await updateUserChannelData(channelId: channelId, data: data) + completionHandler(.success(channelData)) + } catch { + completionHandler(.failure(error)) + } + } + } + + // Mark: Registration of APNS device tokens + + /** + Registers an Apple Push Notification Service token so that the device can receive remote push notifications. This is a convenience method that internally gets the channel data and searches for the token. If it exists, then it's already registered and it returns. If the data does not exists or the token is missing from the array, it's added. + + You can learn more about APNS [here](https://developer.apple.com/documentation/usernotifications/registering_your_app_with_apns). + + - Attention: There's a race condition because the getting/setting of the token are not made in a transaction. + + - Parameters: + - channelId: the id of the APNS channel + - token: the APNS device token as a `String` + */ + func registerTokenForAPNS(channelId: String, token: String) async throws -> ChannelData { + return try await self.channelModule.registerTokenForAPNS(channelId: channelId, token: token) + } + + func registerTokenForAPNS(channelId: String, token: String, completionHandler: @escaping ((Result) -> Void)) { + Task { + do { + let channelData = try await registerTokenForAPNS(channelId: channelId, token: token) + completionHandler(.success(channelData)) + } catch { + completionHandler(.failure(error)) + } + } + } + + /** + Registers an Apple Push Notification Service token so that the device can receive remote push notifications. This is a convenience method that internally gets the channel data and searches for the token. If it exists, then it's already registered and it returns. If the data does not exists or the token is missing from the array, it's added. + + You can learn more about APNS [here](https://developer.apple.com/documentation/usernotifications/registering_your_app_with_apns). + + - Attention: There's a race condition because the getting/setting of the token are not made in a transaction. + + - Parameters: + - channelId: the id of the APNS channel + - token: the APNS device token as `Data` + */ + func registerTokenForAPNS(channelId: String, token: Data) async throws -> ChannelData { + // 1. Convert device token to string + let tokenString = Knock.convertTokenToString(token: token) + return try await self.channelModule.registerTokenForAPNS(channelId: channelId, token: tokenString) + } + + func registerTokenForAPNS(channelId: String, token: Data, completionHandler: @escaping ((Result) -> Void)) { + // 1. Convert device token to string + let tokenString = Knock.convertTokenToString(token: token) + registerTokenForAPNS(channelId: channelId, token: tokenString, completionHandler: completionHandler) + } + + + /** + Unregisters the current deviceId associated to the user so that the device will no longer receive remote push notifications for the provided channelId. + + - Parameters: + - channelId: the id of the APNS channel in Knock + - token: the APNS device token as a `String` + */ + + func unregisterTokenForAPNS(channelId: String, token: String) async throws -> ChannelData { + return try await self.channelModule.unregisterTokenForAPNS(channelId: channelId, token: token) + } + + func unregisterTokenForAPNS(channelId: String, token: String, completionHandler: @escaping ((Result) -> Void)) { + Task { + do { + let channelData = try await unregisterTokenForAPNS(channelId: channelId, token: token) + completionHandler(.success(channelData)) + } catch { + completionHandler(.failure(error)) + } + } + } + + func unregisterTokenForAPNS(channelId: String, token: Data) async throws -> ChannelData { + // 1. Convert device token to string + let tokenString = Knock.convertTokenToString(token: token) + return try await self.channelModule.unregisterTokenForAPNS(channelId: channelId, token: tokenString) + } + + func unregisterTokenForAPNS(channelId: String, token: Data, completionHandler: @escaping ((Result) -> Void)) { + // 1. Convert device token to string + let tokenString = Knock.convertTokenToString(token: token) + unregisterTokenForAPNS(channelId: channelId, token: tokenString, completionHandler: completionHandler) + } + + func getNotificationPermissionStatus(completion: @escaping (UNAuthorizationStatus) -> Void) { + channelModule.userNotificationCenter.getNotificationSettings(completionHandler: { settings in + completion(settings.authorizationStatus) + }) + } + + func getNotificationPermissionStatus() async -> UNAuthorizationStatus { + let settings = await channelModule.userNotificationCenter.notificationSettings() + return settings.authorizationStatus + } + + func requestNotificationPermission(options: UNAuthorizationOptions = [.sound, .badge, .alert], completion: @escaping (UNAuthorizationStatus) -> Void) { + channelModule.userNotificationCenter.requestAuthorization( + options: options, + completionHandler: { _, _ in + self.getNotificationPermissionStatus { permission in + completion(permission) + } + } + ) + } + + func requestNotificationPermission(options: UNAuthorizationOptions = [.sound, .badge, .alert]) async throws -> UNAuthorizationStatus { + try await channelModule.userNotificationCenter.requestAuthorization(options: options) + return await getNotificationPermissionStatus() + } +} diff --git a/Sources/Modules/Channels/ChannelService.swift b/Sources/Modules/Channels/ChannelService.swift index efe98c1..0eca6a0 100644 --- a/Sources/Modules/Channels/ChannelService.swift +++ b/Sources/Modules/Channels/ChannelService.swift @@ -1,6 +1,6 @@ // -// File.swift -// +// ChannelService.swift +// // // Created by Diego on 30/05/23. // @@ -8,142 +8,14 @@ import Foundation import OSLog -public extension Knock { - private var logger: Logger { - Logger(subsystem: Knock.loggingSubsytem, category: "ChannelService") - } - - func getUserChannelData(channelId: String, completionHandler: @escaping ((Result) -> Void)) { - performActionWithUserId( { userId, completion in - self.api.decodeFromGet(ChannelData.self, path: "/users/\(userId)/channel_data/\(channelId)", queryItems: nil, then: completion) - }, completionHandler: completionHandler) - } - - /** - Sets channel data for the user and the channel specified. - - - Parameters: - - channelId: the id of the channel - - data: the shape of the payload varies depending on the channel. You can learn more about channel data schemas [here](https://docs.knock.app/send-notifications/setting-channel-data#provider-data-requirements). - */ - func updateUserChannelData(channelId: String, data: AnyEncodable, completionHandler: @escaping ((Result) -> Void)) { - performActionWithUserId( { userId, completion in - let payload = [ - "data": data - ] - self.api.decodeFromPut(ChannelData.self, path: "/users/\(userId)/channel_data/\(channelId)", body: payload, then: completion) - }, completionHandler: completionHandler) - } - - // Mark: Registration of APNS device tokens +internal class ChannelService: KnockAPIService { - /** - Registers an Apple Push Notification Service token so that the device can receive remote push notifications. This is a convenience method that internally gets the channel data and searches for the token. If it exists, then it's already registered and it returns. If the data does not exists or the token is missing from the array, it's added. - - You can learn more about APNS [here](https://developer.apple.com/documentation/usernotifications/registering_your_app_with_apns). - - - Attention: There's a race condition because the getting/setting of the token are not made in a transaction. - - - Parameters: - - channelId: the id of the APNS channel - - token: the APNS device token as `Data` - */ - func registerTokenForAPNS(channelId: String, token: Data, completionHandler: @escaping ((Result) -> Void)) { - // 1. Convert device token to string - let tokenString = Knock.convertTokenToString(token: token) - - registerTokenForAPNS(channelId: channelId, token: tokenString, completionHandler: completionHandler) + func getUserChannelData(userId: String, channelId: String) async throws -> Knock.ChannelData { + try await get(path: "/users/\(userId)/channel_data/\(channelId)", queryItems: nil) } - /** - Registers an Apple Push Notification Service token so that the device can receive remote push notifications. This is a convenience method that internally gets the channel data and searches for the token. If it exists, then it's already registered and it returns. If the data does not exists or the token is missing from the array, it's added. - - You can learn more about APNS [here](https://developer.apple.com/documentation/usernotifications/registering_your_app_with_apns). - - - Attention: There's a race condition because the getting/setting of the token are not made in a transaction. - - - Parameters: - - channelId: the id of the APNS channel - - token: the APNS device token as a `String` - */ - func registerTokenForAPNS(channelId: String, token: String, completionHandler: @escaping ((Result) -> Void)) { - // Closure to handle token registration/update - let registerOrUpdateToken = { [weak self] (existingTokens: [String]?) in - guard let self = self else { return } - var tokens = existingTokens ?? [] - if !tokens.contains(token) { - tokens.append(token) - } - let data: AnyEncodable = ["tokens": tokens] - self.updateUserChannelData(channelId: channelId, data: data, completionHandler: completionHandler) - } - - getUserChannelData(channelId: channelId) { result in - switch result { - case .failure(_): - // No data registered on that channel for that user, we'll create a new record - registerOrUpdateToken(nil) - completionHandler(.success(ChannelData.init(channel_id: channelId, data: [:]))) - case .success(let channelData): - guard let data = channelData.data, let tokens = data["tokens"]?.value as? [String] else { - // No valid tokens array found, register a new one - registerOrUpdateToken(nil) - return - } - - if tokens.contains(token) { - // Token already registered - completionHandler(.success(channelData)) - } else { - // Register the new token - registerOrUpdateToken(tokens) - } - } - } - } - - /** - Unregisters the current deviceId associated to the user so that the device will no longer receive remote push notifications for the provided channelId. - - - Parameters: - - channelId: the id of the APNS channel in Knock - - token: the APNS device token as a `String` - */ - - func unregisterTokenForAPNS(channelId: String, token: String, completionHandler: @escaping ((Result) -> Void)) { - getUserChannelData(channelId: channelId) { result in - switch result { - case .failure(let error): - if let networkError = error as? NetworkError, networkError.code == 404 { - // No data registered on that channel for that user - self.logger.warning("[Knock] Could not unregister user from channel \(channelId). Reason: User doesn't have any channel data associated to the provided channelId.") - completionHandler(.success(.init(channel_id: channelId, data: [:]))) - } else { - // Unknown error. Could be network or server related. Try again. - self.logger.error("[Knock] Could not unregister user from channel \(channelId). Please try again. Reason: \(error.localizedDescription)") - completionHandler(.failure(error)) - } - - case .success(let channelData): - guard let data = channelData.data, let tokens = data["tokens"]?.value as? [String] else { - // No valid tokens array found. - self.logger.warning("[Knock] Could not unregister user from channel \(channelId). Reason: User doesn't have any device tokens associated to the provided channelId.") - completionHandler(.success(channelData)) - return - } - - if tokens.contains(token) { - let newTokensSet = Set(tokens).subtracting([token]) - let newTokens = Array(newTokensSet) - let data: AnyEncodable = [ - "tokens": newTokens - ] - self.updateUserChannelData(channelId: channelId, data: data, completionHandler: completionHandler) - } else { - self.logger.warning("[Knock] Could not unregister user from channel \(channelId). Reason: User doesn't have any device tokens that match the token provided.") - completionHandler(.success(channelData)) - } - } - } + func updateUserChannelData(userId: String, channelId: String, data: AnyEncodable) async throws -> Knock.ChannelData { + let body = ["data": data] + return try await put(path: "/users/\(userId)/channel_data/\(channelId)", body: body) } } diff --git a/Sources/Modules/Feed/FeedManager.swift b/Sources/Modules/Feed/FeedManager.swift index 5e94b45..84a56ac 100644 --- a/Sources/Modules/Feed/FeedManager.swift +++ b/Sources/Modules/Feed/FeedManager.swift @@ -8,38 +8,41 @@ import Foundation import SwiftPhoenixClient import OSLog +import UIKit public extension Knock { - func initializeFeedManager(feedId: String, options: FeedManager.FeedClientOptions = FeedManager.FeedClientOptions(archived: .exclude)) throws { - guard let safeUserId = self.userId else { throw KnockError.userIdError } - self.feedManager = FeedManager(api: self.api, userId: safeUserId, feedId: feedId, options: options) - } - - func deInitializeFeedManager() { - self.feedManager = nil - } - + class FeedManager { - private let api: KnockAPI - private let userId: String - private let socket: Socket - private var feedChannel: Channel? - private let feedId: String - private var feedTopic: String - private var defaultFeedOptions: FeedClientOptions - private let logger: Logger = Logger(subsystem: Knock.loggingSubsytem, category: "FeedManager") + private let feedModule: FeedModule + private var foregroundObserver: NSObjectProtocol? + private var backgroundObserver: NSObjectProtocol? - internal init(api: KnockAPI, userId: String, feedId: String, options: FeedClientOptions = FeedClientOptions(archived: .exclude)) { - // use regex and circumflex accent to mark only the starting http to be replaced and not any others - let websocketHostname = api.host.replacingOccurrences(of: "^http", with: "ws", options: .regularExpression) // default: wss://api.knock.app - let websocketPath = "\(websocketHostname)/ws/v1/websocket" // default: wss://api.knock.app/ws/v1/websocket - - self.api = api - self.userId = userId - self.socket = Socket(websocketPath, params: ["vsn": "2.0.0", "api_key": api.publishableKey, "user_token": api.userToken ?? ""]) - self.feedId = feedId - self.feedTopic = "feeds:\(feedId):\(userId)" - self.defaultFeedOptions = options + public init(feedId: String, options: FeedClientOptions = FeedClientOptions(archived: .exclude)) throws { + self.feedModule = try FeedModule(feedId: feedId, options: options) + registerForAppLifecycleNotifications() + } + + deinit { + unregisterFromAppLifecycleNotifications() + } + + private func registerForAppLifecycleNotifications() { + foregroundObserver = NotificationCenter.default.addObserver(forName: UIApplication.didBecomeActiveNotification, object: nil, queue: .main) { [weak self] _ in + self?.didEnterForeground() + } + + backgroundObserver = NotificationCenter.default.addObserver(forName: UIApplication.didEnterBackgroundNotification, object: nil, queue: .main) { [weak self] _ in + self?.didEnterBackground() + } + } + + private func unregisterFromAppLifecycleNotifications() { + if let observer = foregroundObserver { + NotificationCenter.default.removeObserver(observer) + } + if let observer = backgroundObserver { + NotificationCenter.default.removeObserver(observer) + } } /** @@ -49,69 +52,15 @@ public extension Knock { - options: options of type `FeedClientOptions` to merge with the default ones (set on the constructor) and scope as much as possible the results */ public func connectToFeed(options: FeedClientOptions? = nil) { - // Setup the socket to receive open/close events - socket.delegateOnOpen(to: self) { (self) in - self.logger.debug("[Knock] Socket Opened") - } - - socket.delegateOnClose(to: self) { (self) in - self.logger.debug("[Knock] Socket Closed") - } - - socket.delegateOnError(to: self) { (self, error) in - let (error, response) = error - if let statusCode = (response as? HTTPURLResponse)?.statusCode, statusCode > 400 { - self.logger.error("[Knock] Socket Errored. \(statusCode)") - self.socket.disconnect() - } else { - self.logger.error("[Knock] Socket Errored. \(error)") - } - } - - // TODO: Determine the level of logging we want from SwiftPhoenixClient. Currently this produces a lot of noise. -// socket.logger = { msg in print("LOG:", msg) } - - let mergedOptions = defaultFeedOptions.mergeOptions(options: options) - - let params = paramsFromOptions(options: mergedOptions) - - // Setup the Channel to receive and send messages - let channel = socket.channel(feedTopic, params: params) - - // Now connect the socket and join the channel - self.feedChannel = channel - self.feedChannel? - .join() - .delegateReceive("ok", to: self) { (self, _) in - self.logger.debug("[Knock] CHANNEL: \(channel.topic) joined") - } - .delegateReceive("error", to: self) { (self, message) in - self.logger.debug("[Knock] CHANNEL: \(channel.topic) failed to join. \(message.payload)") - } - - self.socket.connect() + feedModule.connectToFeed(options: options) } - public func on(eventName: String, completionHandler: @escaping ((Message) -> Void)) { - if let channel = feedChannel { - channel.delegateOn(eventName, to: self) { (self, message) in - completionHandler(message) - } - } - else { - logger.error("[Knock] Feed channel is nil. You should call first connectToFeed()") - } + public func disconnectFromFeed() { + feedModule.disconnectFromFeed() } - public func disconnectFromFeed() { - logger.debug("[Knock] Disconnecting from feed") - - if let channel = self.feedChannel { - channel.leave() - self.socket.remove(channel) - } - - self.socket.disconnect() + public func on(eventName: String, completionHandler: @escaping ((Message) -> Void)) { + feedModule.on(eventName: eventName, completionHandler: completionHandler) } /** @@ -121,25 +70,18 @@ public extension Knock { - options: options of type `FeedClientOptions` to merge with the default ones (set on the constructor) and scope as much as possible the results - completionHandler: the code to execute when the response is received */ + public func getUserFeedContent(options: FeedClientOptions? = nil) async throws -> Feed { + try await self.feedModule.getUserFeedContent(options: options) + } + public func getUserFeedContent(options: FeedClientOptions? = nil, completionHandler: @escaping ((Result) -> Void)) { - let mergedOptions = defaultFeedOptions.mergeOptions(options: options) - - let triggerDataJSON = Knock.encodeGenericDataToJSON(data: mergedOptions.trigger_data) - - let queryItems = [ - URLQueryItem(name: "page_size", value: (mergedOptions.page_size != nil) ? "\(mergedOptions.page_size!)" : nil), - URLQueryItem(name: "after", value: mergedOptions.after), - URLQueryItem(name: "before", value: mergedOptions.before), - URLQueryItem(name: "source", value: mergedOptions.source), - URLQueryItem(name: "tenant", value: mergedOptions.tenant), - URLQueryItem(name: "has_tenant", value: (mergedOptions.has_tenant != nil) ? "true" : "false"), - URLQueryItem(name: "status", value: (mergedOptions.status != nil) ? mergedOptions.status?.rawValue : ""), - URLQueryItem(name: "archived", value: (mergedOptions.archived != nil) ? mergedOptions.archived?.rawValue : ""), - URLQueryItem(name: "trigger_data", value: triggerDataJSON) - ] - - api.decodeFromGet(Feed.self, path: "/users/\(userId)/feeds/\(feedId)", queryItems: queryItems) { (result) in - completionHandler(result) + Task { + do { + let feed = try await getUserFeedContent(options: options) + completionHandler(.success(feed)) + } catch { + completionHandler(.failure(error)) + } } } @@ -153,56 +95,27 @@ public extension Knock { - options: all the options currently set on the feed to scope as much as possible the bulk update - completionHandler: the code to execute when the response is received */ - public func makeBulkStatusUpdate(type: BulkChannelMessageStatusUpdateType, options: FeedClientOptions, completionHandler: @escaping ((Result) -> Void)) { - // TODO: check https://docs.knock.app/reference#bulk-update-channel-message-status - // older_than: ISO-8601, check milliseconds - // newer_than: ISO-8601, check milliseconds - // delivery_status: one of `queued`, `sent`, `delivered`, `delivery_attempted`, `undelivered`, `not_sent` - // engagement_status: one of `seen`, `unseen`, `read`, `unread`, `archived`, `unarchived`, `interacted` - // Also check if the parameters sent here are valid - let body: AnyEncodable = [ - "user_ids": [userId], - "engagement_status": options.status != nil && options.status != .all ? options.status!.rawValue : "", - "archived": options.archived ?? "", - "has_tenant": options.has_tenant ?? "", - "tenants": (options.tenant != nil) ? [options.tenant!] : "" - ] - - api.decodeFromPost(BulkOperation.self, path: "/channels/\(feedId)/messages/bulk/\(type.rawValue)", body: body, then: completionHandler) + public func makeBulkStatusUpdate(type: BulkChannelMessageStatusUpdateType, options: FeedClientOptions) async throws -> BulkOperation { + try await feedModule.makeBulkStatusUpdate(type: type, options: options) } - private func paramsFromOptions(options: FeedClientOptions) -> [String: Any] { - var params: [String: Any] = [:] - - if let value = options.before { - params["before"] = value - } - if let value = options.after { - params["after"] = value - } - if let value = options.page_size { - params["page_size"] = value - } - if let value = options.status { - params["status"] = value.rawValue - } - if let value = options.source { - params["source"] = value - } - if let value = options.tenant { - params["tenant"] = value - } - if let value = options.has_tenant { - params["has_tenant"] = value - } - if let value = options.archived { - params["archived"] = value.rawValue - } - if let value = options.trigger_data { - params["trigger_data"] = value.dictionary() + public func makeBulkStatusUpdate(type: BulkChannelMessageStatusUpdateType, options: FeedClientOptions, completionHandler: @escaping ((Result) -> Void)) { + Task { + do { + let operation = try await makeBulkStatusUpdate(type: type, options: options) + completionHandler(.success(operation)) + } catch { + completionHandler(.failure(error)) + } } - - return params + } + + public func didEnterForeground() { + Knock.shared.feedManager?.connectToFeed() + } + + public func didEnterBackground() { + Knock.shared.feedManager?.disconnectFromFeed() } } } diff --git a/Sources/Modules/Feed/FeedModule.swift b/Sources/Modules/Feed/FeedModule.swift new file mode 100644 index 0000000..d850403 --- /dev/null +++ b/Sources/Modules/Feed/FeedModule.swift @@ -0,0 +1,193 @@ +// +// FeedService.swift +// +// +// Created by Matt Gardner on 1/29/24. +// + +import Foundation +import SwiftPhoenixClient +import OSLog + +internal class FeedModule { + private let socket: Socket + private var feedChannel: Channel? + private let feedId: String + private var feedTopic: String + private var feedOptions: Knock.FeedClientOptions + private let feedService = FeedService() + + internal init(feedId: String, options: Knock.FeedClientOptions) throws { + // use regex and circumflex accent to mark only the starting http to be replaced and not any others + let websocketHostname = Knock.shared.environment.baseUrl.replacingOccurrences(of: "^http", with: "ws", options: .regularExpression) // default: wss://api.knock.app + let websocketPath = "\(websocketHostname)/ws/v1/websocket" // default: wss://api.knock.app/ws/v1/websocket + var userId = "" + do { + userId = try Knock.shared.environment.getSafeUserId() + } catch let error { + Knock.shared.log(type: .error, category: .feed, message: "FeedManager", status: .fail, errorMessage: "Must sign user in before initializing the FeedManager") + throw error + } + + self.socket = Socket(websocketPath, params: ["vsn": "2.0.0", "api_key": try Knock.shared.environment.getSafePublishableKey(), "user_token": Knock.shared.environment.userToken ?? ""]) + self.feedId = feedId + self.feedTopic = "feeds:\(feedId):\(userId)" + self.feedOptions = options + Knock.shared.log(type: .debug, category: .feed, message: "FeedManager", status: .success) + } + + func getUserFeedContent(options: Knock.FeedClientOptions? = nil) async throws -> Knock.Feed { + let mergedOptions = feedOptions.mergeOptions(options: options) + + let triggerDataJSON = Knock.encodeGenericDataToJSON(data: mergedOptions.trigger_data) + + let queryItems = [ + URLQueryItem(name: "page_size", value: (mergedOptions.page_size != nil) ? "\(mergedOptions.page_size!)" : nil), + URLQueryItem(name: "after", value: mergedOptions.after), + URLQueryItem(name: "before", value: mergedOptions.before), + URLQueryItem(name: "source", value: mergedOptions.source), + URLQueryItem(name: "tenant", value: mergedOptions.tenant), + URLQueryItem(name: "has_tenant", value: (mergedOptions.has_tenant != nil) ? "true" : "false"), + URLQueryItem(name: "status", value: (mergedOptions.status != nil) ? mergedOptions.status?.rawValue : ""), + URLQueryItem(name: "archived", value: (mergedOptions.archived != nil) ? mergedOptions.archived?.rawValue : ""), + URLQueryItem(name: "trigger_data", value: triggerDataJSON) + ] + + do { + let feed = try await feedService.getUserFeedContent(userId: Knock.shared.environment.getSafeUserId(), queryItems: queryItems, feedId: feedId) + Knock.shared.log(type: .debug, category: .feed, message: "getUserFeedContent", status: .success) + return feed + } catch let error { + Knock.shared.log(type: .error, category: .feed, message: "getUserFeedContent", status: .fail, errorMessage: error.localizedDescription) + throw error + } + } + + func makeBulkStatusUpdate(type: Knock.BulkChannelMessageStatusUpdateType, options: Knock.FeedClientOptions) async throws -> Knock.BulkOperation { + // TODO: check https://docs.knock.app/reference#bulk-update-channel-message-status + // older_than: ISO-8601, check milliseconds + // newer_than: ISO-8601, check milliseconds + // delivery_status: one of `queued`, `sent`, `delivered`, `delivery_attempted`, `undelivered`, `not_sent` + // engagement_status: one of `seen`, `unseen`, `read`, `unread`, `archived`, `unarchived`, `interacted` + // Also check if the parameters sent here are valid + let userId = try Knock.shared.environment.getSafeUserId() + let body: AnyEncodable = [ + "user_ids": [userId], + "engagement_status": options.status != nil && options.status != .all ? options.status!.rawValue : "", + "archived": options.archived ?? "", + "has_tenant": options.has_tenant ?? "", + "tenants": (options.tenant != nil) ? [options.tenant!] : "" + ] + do { + let op = try await feedService.makeBulkStatusUpdate(feedId: feedId, type: type, body: body) + Knock.shared.log(type: .debug, category: .feed, message: "makeBulkStatusUpdate", status: .success) + return op + } catch let error { + Knock.shared.log(type: .error, category: .feed, message: "makeBulkStatusUpdate", status: .fail, errorMessage: error.localizedDescription) + throw error + } + } + + func disconnectFromFeed() { + Knock.shared.log(type: .debug, category: .feed, message: "Disconnecting from feed") + + if let channel = self.feedChannel { + channel.leave() + self.socket.remove(channel) + } + + self.socket.disconnect() + } + + // Todo: Make AsyncStream method for this + func on(eventName: String, completionHandler: @escaping ((Message) -> Void)) { + if let channel = feedChannel { + channel.delegateOn(eventName, to: self) { (self, message) in + completionHandler(message) + } + } + else { + Knock.shared.log(type: .error, category: .feed, message: "FeedManager.on", status: .fail, errorMessage: "Feed channel is nil. You should call first connectToFeed()") + } + } + + func connectToFeed(options: Knock.FeedClientOptions? = nil) { + // Setup the socket to receive open/close events + socket.delegateOnOpen(to: self) { (self) in + Knock.shared.log(type: .debug, category: .feed, message: "connectToFeed", description: "Socket Opened") + } + + socket.delegateOnClose(to: self) { (self) in + Knock.shared.log(type: .debug, category: .feed, message: "connectToFeed", description: "Socket Closed") + } + + socket.delegateOnError(to: self) { (self, error) in + let (error, response) = error + if let statusCode = (response as? HTTPURLResponse)?.statusCode, statusCode > 400 { + Knock.shared.log(type: .error, category: .feed, message: "connectToFeed", description: "Socket Errored \(statusCode)", status: .fail, errorMessage: error.localizedDescription) + self.socket.disconnect() + } else { + Knock.shared.log(type: .error, category: .feed, message: "connectToFeed", description: "Socket Errored", status: .fail, errorMessage: error.localizedDescription) + } + } + + // TODO: Determine the level of logging we want from SwiftPhoenixClient. Currently this produces a lot of noise. + socket.logger = { msg in + Knock.shared.log(type: .debug, category: .feed, message: "SwiftPhoenixClient", description: msg) + } + + let mergedOptions = feedOptions.mergeOptions(options: options) + + let params = paramsFromOptions(options: mergedOptions) + + // Setup the Channel to receive and send messages + let channel = socket.channel(feedTopic, params: params) + + // Now connect the socket and join the channel + self.feedChannel = channel + self.feedChannel? + .join() + .delegateReceive("ok", to: self) { (self, _) in + Knock.shared.log(type: .debug, category: .feed, message: "connectToFeed", description: "CHANNEL: \(channel.topic) joined") + } + .delegateReceive("error", to: self) { (self, message) in + Knock.shared.log(type: .error, category: .feed, message: "connectToFeed", status: .fail, errorMessage: "CHANNEL: \(channel.topic) failed to join. \(message.payload)") + } + + self.socket.connect() + } + + private func paramsFromOptions(options: Knock.FeedClientOptions) -> [String: Any] { + var params: [String: Any] = [:] + + if let value = options.before { + params["before"] = value + } + if let value = options.after { + params["after"] = value + } + if let value = options.page_size { + params["page_size"] = value + } + if let value = options.status { + params["status"] = value.rawValue + } + if let value = options.source { + params["source"] = value + } + if let value = options.tenant { + params["tenant"] = value + } + if let value = options.has_tenant { + params["has_tenant"] = value + } + if let value = options.archived { + params["archived"] = value.rawValue + } + if let value = options.trigger_data { + params["trigger_data"] = value.dictionary() + } + + return params + } +} diff --git a/Sources/Modules/Feed/FeedService.swift b/Sources/Modules/Feed/FeedService.swift new file mode 100644 index 0000000..47e38e3 --- /dev/null +++ b/Sources/Modules/Feed/FeedService.swift @@ -0,0 +1,18 @@ +// +// FeedService.swift +// +// +// Created by Matt Gardner on 1/29/24. +// + +import Foundation + +internal class FeedService: KnockAPIService { + func getUserFeedContent(userId: String, queryItems: [URLQueryItem]?, feedId: String) async throws -> Knock.Feed { + try await get(path: "/users/\(userId)/feeds/\(feedId)", queryItems: queryItems) + } + + func makeBulkStatusUpdate(feedId: String, type: Knock.BulkChannelMessageStatusUpdateType, body: AnyEncodable?) async throws -> Knock.BulkOperation { + try await post(path: "/channels/\(feedId)/messages/bulk/\(type.rawValue)", body: body) + } +} diff --git a/Sources/Modules/Feed/Models/Block.swift b/Sources/Modules/Feed/Models/Block.swift index dfc9c37..28c3d28 100644 --- a/Sources/Modules/Feed/Models/Block.swift +++ b/Sources/Modules/Feed/Models/Block.swift @@ -1,5 +1,5 @@ // -// File.swift +// Block.swift // // // Created by Matt Gardner on 1/18/24. diff --git a/Sources/Modules/Feed/Models/BulkOperation.swift b/Sources/Modules/Feed/Models/BulkOperation.swift index aadedf7..6487ce8 100644 --- a/Sources/Modules/Feed/Models/BulkOperation.swift +++ b/Sources/Modules/Feed/Models/BulkOperation.swift @@ -1,5 +1,5 @@ // -// File.swift +// BulkOperation.swift // // // Created by Matt Gardner on 1/19/24. diff --git a/Sources/Modules/Feed/Models/Feed.swift b/Sources/Modules/Feed/Models/Feed.swift index 121bd90..ff957b8 100644 --- a/Sources/Modules/Feed/Models/Feed.swift +++ b/Sources/Modules/Feed/Models/Feed.swift @@ -1,5 +1,5 @@ // -// File.swift +// Feed.swift // // // Created by Matt Gardner on 1/19/24. diff --git a/Sources/Modules/Feed/Models/FeedClientOptions.swift b/Sources/Modules/Feed/Models/FeedClientOptions.swift index 353e9bd..15ac505 100644 --- a/Sources/Modules/Feed/Models/FeedClientOptions.swift +++ b/Sources/Modules/Feed/Models/FeedClientOptions.swift @@ -7,7 +7,7 @@ import Foundation -extension Knock.FeedManager { +extension Knock { public struct FeedClientOptions: Codable { public var before: String? public var after: String? @@ -20,16 +20,16 @@ extension Knock.FeedManager { public var trigger_data: [String: AnyCodable]? // GenericData public init(from decoder: Decoder) throws { - let container: KeyedDecodingContainer = try decoder.container(keyedBy: Knock.FeedManager.FeedClientOptions.CodingKeys.self) - self.before = try container.decodeIfPresent(String.self, forKey: Knock.FeedManager.FeedClientOptions.CodingKeys.before) - self.after = try container.decodeIfPresent(String.self, forKey: Knock.FeedManager.FeedClientOptions.CodingKeys.after) - self.page_size = try container.decodeIfPresent(Int.self, forKey: Knock.FeedManager.FeedClientOptions.CodingKeys.page_size) - self.status = try container.decodeIfPresent(Knock.FeedManager.FeedItemScope.self, forKey: Knock.FeedManager.FeedClientOptions.CodingKeys.status) - self.source = try container.decodeIfPresent(String.self, forKey: Knock.FeedManager.FeedClientOptions.CodingKeys.source) - self.tenant = try container.decodeIfPresent(String.self, forKey: Knock.FeedManager.FeedClientOptions.CodingKeys.tenant) - self.has_tenant = try container.decodeIfPresent(Bool.self, forKey: Knock.FeedManager.FeedClientOptions.CodingKeys.has_tenant) - self.archived = try container.decodeIfPresent(Knock.FeedManager.FeedItemArchivedScope.self, forKey: Knock.FeedManager.FeedClientOptions.CodingKeys.archived) - self.trigger_data = try container.decodeIfPresent([String : AnyCodable].self, forKey: Knock.FeedManager.FeedClientOptions.CodingKeys.trigger_data) + let container: KeyedDecodingContainer = try decoder.container(keyedBy: FeedClientOptions.CodingKeys.self) + self.before = try container.decodeIfPresent(String.self, forKey: FeedClientOptions.CodingKeys.before) + self.after = try container.decodeIfPresent(String.self, forKey: FeedClientOptions.CodingKeys.after) + self.page_size = try container.decodeIfPresent(Int.self, forKey: FeedClientOptions.CodingKeys.page_size) + self.status = try container.decodeIfPresent(Knock.FeedItemScope.self, forKey: FeedClientOptions.CodingKeys.status) + self.source = try container.decodeIfPresent(String.self, forKey: FeedClientOptions.CodingKeys.source) + self.tenant = try container.decodeIfPresent(String.self, forKey: FeedClientOptions.CodingKeys.tenant) + self.has_tenant = try container.decodeIfPresent(Bool.self, forKey: FeedClientOptions.CodingKeys.has_tenant) + self.archived = try container.decodeIfPresent(Knock.FeedItemArchivedScope.self, forKey: FeedClientOptions.CodingKeys.archived) + self.trigger_data = try container.decodeIfPresent([String : AnyCodable].self, forKey: FeedClientOptions.CodingKeys.trigger_data) } public init(before: String? = nil, after: String? = nil, page_size: Int? = nil, status: FeedItemScope? = nil, source: String? = nil, tenant: String? = nil, has_tenant: Bool? = nil, archived: FeedItemArchivedScope? = nil, trigger_data: [String : AnyCodable]? = nil) { diff --git a/Sources/Modules/Feed/Models/FeedItemScope.swift b/Sources/Modules/Feed/Models/FeedItemScope.swift index c2f98ad..e3688f6 100644 --- a/Sources/Modules/Feed/Models/FeedItemScope.swift +++ b/Sources/Modules/Feed/Models/FeedItemScope.swift @@ -8,7 +8,7 @@ import Foundation -extension Knock.FeedManager { +extension Knock { public enum FeedItemScope: String, Codable { // TODO: check engagement_status in https://docs.knock.app/reference#bulk-update-channel-message-status // extras: diff --git a/Sources/Modules/Messages/MessageModule.swift b/Sources/Modules/Messages/MessageModule.swift new file mode 100644 index 0000000..7b19c88 --- /dev/null +++ b/Sources/Modules/Messages/MessageModule.swift @@ -0,0 +1,182 @@ +// +// MessageModule.swift +// +// +// Created by Matt Gardner on 1/29/24. +// + +import Foundation + +internal class MessageModule { + let messageService = MessageService() + + internal func getMessage(messageId: String) async throws -> Knock.KnockMessage { + do { + let message = try await messageService.getMessage(messageId: messageId) + Knock.shared.log(type: .debug, category: .message, message: "getMessage", status: .success, additionalInfo: ["messageId": messageId]) + return message + } catch let error { + Knock.shared.log(type: .error, category: .message, message: "getMessage", status: .fail, errorMessage: error.localizedDescription, additionalInfo: ["messageId": messageId]) + throw error + } + } + + internal func updateMessageStatus(messageId: String, status: Knock.KnockMessageStatusUpdateType) async throws -> Knock.KnockMessage { + do { + let message = try await messageService.updateMessageStatus(messageId: messageId, status: status) + Knock.shared.log(type: .debug, category: .message, message: "updateMessageStatus", status: .success, additionalInfo: ["messageId": messageId]) + return message + } catch let error { + Knock.shared.log(type: .error, category: .message, message: "updateMessageStatus", status: .fail, errorMessage: error.localizedDescription, additionalInfo: ["messageId": messageId]) + throw error + } + } + + internal func deleteMessageStatus(messageId: String, status: Knock.KnockMessageStatusUpdateType) async throws -> Knock.KnockMessage { + do { + let message = try await messageService.deleteMessageStatus(messageId: messageId, status: status) + Knock.shared.log(type: .debug, category: .message, message: "deleteMessageStatus", status: .success, additionalInfo: ["messageId": messageId]) + return message + } catch let error { + Knock.shared.log(type: .error, category: .message, message: "deleteMessageStatus", status: .fail, errorMessage: error.localizedDescription, additionalInfo: ["messageId": messageId]) + throw error + } + } + + internal func batchUpdateStatuses(messageIds: [String], status: Knock.KnockMessageStatusBatchUpdateType) async throws -> [Knock.KnockMessage] { + do { + let messages = try await messageService.batchUpdateStatuses(messageIds: messageIds, status: status) + Knock.shared.log(type: .debug, category: .message, message: "batchUpdateStatuses", status: .success) + return messages + } catch let error { + Knock.shared.log(type: .error, category: .message, message: "batchUpdateStatuses", status: .fail, errorMessage: error.localizedDescription) + throw error + } + } +} + +public extension Knock { + + func getMessage(messageId: String) async throws -> KnockMessage { + try await self.messageModule.getMessage(messageId: messageId) + } + + func getMessage(messageId: String, completionHandler: @escaping ((Result) -> Void)) { + Task { + do { + let message = try await getMessage(messageId: messageId) + completionHandler(.success(message)) + } catch { + completionHandler(.failure(error)) + } + } + } + + func updateMessageStatus(message: KnockMessage, status: KnockMessageStatusUpdateType) async throws -> KnockMessage { + try await self.messageModule.updateMessageStatus(messageId: message.id, status: status) + } + + func updateMessageStatus(message: KnockMessage, status: KnockMessageStatusUpdateType, completionHandler: @escaping ((Result) -> Void)) { + Task { + do { + let message = try await updateMessageStatus(message: message, status: status) + completionHandler(.success(message)) + } catch { + completionHandler(.failure(error)) + } + } + } + + func updateMessageStatus(messageId: String, status: KnockMessageStatusUpdateType) async throws -> KnockMessage { + try await self.messageModule.updateMessageStatus(messageId: messageId, status: status) + } + + func updateMessageStatus(messageId: String, status: KnockMessageStatusUpdateType, completionHandler: @escaping ((Result) -> Void)) { + Task { + do { + let message = try await updateMessageStatus(messageId: messageId, status: status) + completionHandler(.success(message)) + } catch { + completionHandler(.failure(error)) + } + } + } + + func deleteMessageStatus(message: KnockMessage, status: KnockMessageStatusUpdateType) async throws -> KnockMessage { + try await self.messageModule.deleteMessageStatus(messageId: message.id, status: status) + } + + func deleteMessageStatus(message: KnockMessage, status: KnockMessageStatusUpdateType, completionHandler: @escaping ((Result) -> Void)) { + Task { + do { + let message = try await deleteMessageStatus(message: message, status: status) + completionHandler(.success(message)) + } catch { + completionHandler(.failure(error)) + } + } + } + + func deleteMessageStatus(messageId: String, status: KnockMessageStatusUpdateType) async throws -> KnockMessage { + try await self.messageModule.updateMessageStatus(messageId: messageId, status: status) + } + + func deleteMessageStatus(messageId: String, status: KnockMessageStatusUpdateType, completionHandler: @escaping ((Result) -> Void)) { + Task { + do { + let message = try await deleteMessageStatus(messageId: messageId, status: status) + completionHandler(.success(message)) + } catch { + completionHandler(.failure(error)) + } + } + } + + + /** + Batch status update for a list of messages + + - Parameters: + - messageIds: the list of message ids: `[String]` to be updated + - status: the new `Status` + - completionHandler: the code to execute when the response is received + */ + func batchUpdateStatuses(messageIds: [String], status: KnockMessageStatusBatchUpdateType) async throws -> [KnockMessage] { + try await self.messageModule.batchUpdateStatuses(messageIds: messageIds, status: status) + } + + func batchUpdateStatuses(messageIds: [String], status: KnockMessageStatusBatchUpdateType, completionHandler: @escaping ((Result<[KnockMessage], Error>) -> Void)) { + Task { + do { + let messages = try await batchUpdateStatuses(messageIds: messageIds, status: status) + completionHandler(.success(messages)) + } catch { + completionHandler(.failure(error)) + } + } + } + + /** + Batch status update for a list of messages + + - Parameters: + - messages: the list of messages `[KnockMessage]` to be updated + - status: the new `Status` + - completionHandler: the code to execute when the response is received + */ + func batchUpdateStatuses(messages: [KnockMessage], status: KnockMessageStatusBatchUpdateType) async throws -> [KnockMessage] { + let messageIds = messages.map{$0.id} + return try await self.messageModule.batchUpdateStatuses(messageIds: messageIds, status: status) + } + + func batchUpdateStatuses(messages: [KnockMessage], status: KnockMessageStatusBatchUpdateType, completionHandler: @escaping ((Result<[KnockMessage], Error>) -> Void)) { + Task { + do { + let messages = try await batchUpdateStatuses(messages: messages, status: status) + completionHandler(.success(messages)) + } catch { + completionHandler(.failure(error)) + } + } + } +} diff --git a/Sources/Modules/Messages/MessageService.swift b/Sources/Modules/Messages/MessageService.swift index 56a863e..cbeecab 100644 --- a/Sources/Modules/Messages/MessageService.swift +++ b/Sources/Modules/Messages/MessageService.swift @@ -7,58 +7,22 @@ import Foundation -public extension Knock { +internal class MessageService: KnockAPIService { - func getMessage(messageId: String, completionHandler: @escaping ((Result) -> Void)) { - self.api.decodeFromGet(KnockMessage.self, path: "/messages/\(messageId)", queryItems: nil, then: completionHandler) + internal func getMessage(messageId: String) async throws -> Knock.KnockMessage { + try await get(path: "/messages/\(messageId)", queryItems: nil) } - func updateMessageStatus(message: KnockMessage, status: KnockMessageStatusUpdateType, completionHandler: @escaping ((Result) -> Void)) { - updateMessageStatus(messageId: message.id, status: status, completionHandler: completionHandler) + internal func updateMessageStatus(messageId: String, status: Knock.KnockMessageStatusUpdateType) async throws -> Knock.KnockMessage { + try await put(path: "/messages/\(messageId)/\(status.rawValue)", body: nil) } - func updateMessageStatus(messageId: String, status: KnockMessageStatusUpdateType, completionHandler: @escaping ((Result) -> Void)) { - self.api.decodeFromPut(KnockMessage.self, path: "/messages/\(messageId)/\(status.rawValue)", body: nil, then: completionHandler) + internal func deleteMessageStatus(messageId: String, status: Knock.KnockMessageStatusUpdateType) async throws -> Knock.KnockMessage { + try await delete(path: "/messages/\(messageId)/\(status.rawValue)", body: nil) } - func deleteMessageStatus(message: KnockMessage, status: KnockMessageStatusUpdateType, completionHandler: @escaping ((Result) -> Void)) { - deleteMessageStatus(messageId: message.id, status: status, completionHandler: completionHandler) - } - - func deleteMessageStatus(messageId: String, status: KnockMessageStatusUpdateType, completionHandler: @escaping ((Result) -> Void)) { - self.api.decodeFromDelete(KnockMessage.self, path: "/messages/\(messageId)/\(status.rawValue)", body: nil, then: completionHandler) - } - - /** - Batch status update for a list of messages - - - Parameters: - - messageIds: the list of message ids: `[String]` to be updated - - status: the new `Status` - - completionHandler: the code to execute when the response is received - */ - func batchUpdateStatuses(messageIds: [String], status: KnockMessageStatusBatchUpdateType, completionHandler: @escaping ((Result<[KnockMessage], Error>) -> Void)) { - let body = [ - "message_ids": messageIds - ] - - api.decodeFromPost([KnockMessage].self, path: "/messages/batch/\(status.rawValue)", body: body, then: completionHandler) - } - - /** - Batch status update for a list of messages - - - Parameters: - - messages: the list of messages `[KnockMessage]` to be updated - - status: the new `Status` - - completionHandler: the code to execute when the response is received - */ - func batchUpdateStatuses(messages: [KnockMessage], status: KnockMessageStatusBatchUpdateType, completionHandler: @escaping ((Result<[KnockMessage], Error>) -> Void)) { - let messageIds = messages.map{$0.id} - let body = [ - "message_ids": messageIds - ] - - api.decodeFromPost([KnockMessage].self, path: "/messages/batch/\(status.rawValue)", body: body, then: completionHandler) + internal func batchUpdateStatuses(messageIds: [String], status: Knock.KnockMessageStatusBatchUpdateType) async throws -> [Knock.KnockMessage] { + let body = ["message_ids": messageIds] + return try await post(path: "/messages/batch/\(status.rawValue)", body: body) } } diff --git a/Sources/Modules/Preferences/PreferenceModule.swift b/Sources/Modules/Preferences/PreferenceModule.swift new file mode 100644 index 0000000..9cc0c1e --- /dev/null +++ b/Sources/Modules/Preferences/PreferenceModule.swift @@ -0,0 +1,93 @@ +// +// PreferenceModule.swift +// +// +// Created by Matt Gardner on 1/29/24. +// + +import Foundation +import OSLog + +internal class PreferenceModule { + let preferenceService = PreferenceService() + + internal func getAllUserPreferences() async throws -> [Knock.PreferenceSet] { + do { + let set = try await preferenceService.getAllUserPreferences(userId: Knock.shared.environment.getSafeUserId()) + Knock.shared.log(type: .debug, category: .preferences, message: "getAllUserPreferences", status: .success) + return set + } catch let error { + Knock.shared.log(type: .error, category: .preferences, message: "getAllUserPreferences", status: .fail, errorMessage: error.localizedDescription) + throw error + } + } + + internal func getUserPreferences(preferenceId: String) async throws -> Knock.PreferenceSet { + do { + let set = try await preferenceService.getUserPreferences(userId: Knock.shared.environment.getSafeUserId(), preferenceId: preferenceId) + Knock.shared.log(type: .debug, category: .preferences, message: "getUserPreferences", status: .success) + return set + } catch let error { + Knock.shared.log(type: .error, category: .preferences, message: "getUserPreferences", status: .fail, errorMessage: error.localizedDescription) + throw error + } + } + + internal func setUserPreferences(preferenceId: String, preferenceSet: Knock.PreferenceSet) async throws -> Knock.PreferenceSet { + do { + let set = try await preferenceService.setUserPreferences(userId: Knock.shared.environment.getSafeUserId(), preferenceId: preferenceId, preferenceSet: preferenceSet) + Knock.shared.log(type: .debug, category: .preferences, message: "setUserPreferences", status: .success) + return set + } catch let error { + Knock.shared.log(type: .error, category: .preferences, message: "setUserPreferences", status: .fail, errorMessage: error.localizedDescription) + throw error + } + } +} + +public extension Knock { + func getAllUserPreferences() async throws -> [Knock.PreferenceSet] { + try await self.preferenceModule.getAllUserPreferences() + } + + func getAllUserPreferences(completionHandler: @escaping ((Result<[PreferenceSet], Error>) -> Void)) { + Task { + do { + let preferences = try await getAllUserPreferences() + completionHandler(.success(preferences)) + } catch { + completionHandler(.failure(error)) + } + } + } + + func getUserPreferences(preferenceId: String) async throws -> Knock.PreferenceSet { + try await self.preferenceModule.getUserPreferences(preferenceId: preferenceId) + } + + func getUserPreferences(preferenceId: String, completionHandler: @escaping ((Result) -> Void)) { + Task { + do { + let preferences = try await getUserPreferences(preferenceId: preferenceId) + completionHandler(.success(preferences)) + } catch { + completionHandler(.failure(error)) + } + } + } + + func setUserPreferences(preferenceId: String, preferenceSet: PreferenceSet) async throws -> Knock.PreferenceSet { + try await self.preferenceModule.setUserPreferences(preferenceId: preferenceId, preferenceSet: preferenceSet) + } + + func setUserPreferences(preferenceId: String, preferenceSet: PreferenceSet, completionHandler: @escaping ((Result) -> Void)) { + Task { + do { + let preferences = try await setUserPreferences(preferenceId: preferenceId, preferenceSet: preferenceSet) + completionHandler(.success(preferences)) + } catch { + completionHandler(.failure(error)) + } + } + } +} diff --git a/Sources/Modules/Preferences/PreferenceService.swift b/Sources/Modules/Preferences/PreferenceService.swift index f2c431a..c20d9cd 100644 --- a/Sources/Modules/Preferences/PreferenceService.swift +++ b/Sources/Modules/Preferences/PreferenceService.swift @@ -1,5 +1,5 @@ // -// File.swift +// PreferenceService.swift // // // Created by Matt Gardner on 1/19/24. @@ -7,24 +7,17 @@ import Foundation -public extension Knock { - - func getAllUserPreferences(completionHandler: @escaping ((Result<[PreferenceSet], Error>) -> Void)) { - performActionWithUserId( { userId, completion in - self.api.decodeFromGet([PreferenceSet].self, path: "/users/\(userId)/preferences", queryItems: nil, then: completion) - }, completionHandler: completionHandler) +internal class PreferenceService: KnockAPIService { + + internal func getAllUserPreferences(userId: String) async throws -> [Knock.PreferenceSet] { + try await get(path: "/users/\(userId)/preferences", queryItems: nil) } - func getUserPreferences(preferenceId: String, completionHandler: @escaping ((Result) -> Void)) { - performActionWithUserId( { userId, completion in - self.api.decodeFromGet(PreferenceSet.self, path: "/users/\(userId)/preferences/\(preferenceId)", queryItems: nil, then: completion) - }, completionHandler: completionHandler) + internal func getUserPreferences(userId: String, preferenceId: String) async throws -> Knock.PreferenceSet { + try await get(path: "/users/\(userId)/preferences/\(preferenceId)", queryItems: nil) } - func setUserPreferences(preferenceId: String, preferenceSet: PreferenceSet, completionHandler: @escaping ((Result) -> Void)) { - performActionWithUserId( { userId, completion in - let payload = preferenceSet - self.api.decodeFromPut(PreferenceSet.self, path: "/users/\(userId)/preferences/\(preferenceId)", body: payload, then: completion) - }, completionHandler: completionHandler) + internal func setUserPreferences(userId: String, preferenceId: String, preferenceSet: Knock.PreferenceSet) async throws -> Knock.PreferenceSet { + try await put(path: "/users/\(userId)/preferences/\(preferenceId)", body: preferenceSet) } } diff --git a/Sources/Modules/Users/UserModule.swift b/Sources/Modules/Users/UserModule.swift new file mode 100644 index 0000000..ff06b1a --- /dev/null +++ b/Sources/Modules/Users/UserModule.swift @@ -0,0 +1,69 @@ +// +// File.swift +// +// +// Created by Matt Gardner on 1/26/24. +// + +import Foundation +import OSLog + +internal class UserModule { + let userService = UserService() + + func getUser() async throws -> Knock.User { + do { + let user = try await userService.getUser(userId: Knock.shared.environment.getSafeUserId()) + Knock.shared.log(type: .debug, category: .user, message: "getUser", status: .success) + return user + } catch let error { + Knock.shared.log(type: .error, category: .user, message: "getUser", status: .fail, errorMessage: error.localizedDescription) + throw error + } + } + + func updateUser(user: Knock.User) async throws -> Knock.User { + do { + let user = try await userService.updateUser(user: user) + Knock.shared.log(type: .debug, category: .user, message: "updateUser", status: .success) + return user + } catch let error { + Knock.shared.log(type: .error, category: .user, message: "updateUser", status: .fail, errorMessage: error.localizedDescription) + throw error + } + } +} + +public extension Knock { + + func getUser() async throws -> User { + return try await userModule.getUser() + } + + func getUser(completionHandler: @escaping ((Result) -> Void)) { + Task { + do { + let user = try await getUser() + completionHandler(.success(user)) + } catch { + completionHandler(.failure(error)) + } + } + } + + func updateUser(user: User) async throws -> User { + return try await userModule.updateUser(user: user) + } + + func updateUser(user: User, completionHandler: @escaping ((Result) -> Void)) { + Task { + do { + let user = try await updateUser(user: user) + completionHandler(.success(user)) + } catch { + completionHandler(.failure(error)) + } + } + } +} + diff --git a/Sources/Modules/Users/UserService.swift b/Sources/Modules/Users/UserService.swift index 6889217..57cbd86 100644 --- a/Sources/Modules/Users/UserService.swift +++ b/Sources/Modules/Users/UserService.swift @@ -6,83 +6,19 @@ // import Foundation -import OSLog -public extension Knock { - - private var logger: Logger { - Logger(subsystem: Knock.loggingSubsytem, category: "UserService") - } - - /** - Sets the user for the current Knock instance. Will also register the device for push notifications if token and channelId are provided. - You can also register the device for push notifications later on by calling Knock.registerTokenForAPNS() +internal protocol UserServiceProtocol { + func getUser(userId: String) async throws -> Knock.User + func updateUser(user: Knock.User) async throws -> Knock.User +} - - Parameters: - - userId: the user-id that will be used in the subsequent method calls - - userToken: [optional] user token. Used in production when enhanced security is enabled - - deviceToken: [optional] Options for customizing the Knock instance. - - pushChannelId: [optional] Options for customizing the Knock instance. - */ - func authenticate(userId: String, userToken: String? = nil, deviceToken: String? = nil, pushChannelId: String? = nil, completion: @escaping (Result) -> Void) { - self.userId = userId - self.pushChannelId = pushChannelId - self.userDeviceToken = deviceToken - self.api.userToken = userToken - if let token = deviceToken, let channelId = pushChannelId { - self.registerTokenForAPNS(channelId: channelId, token: token) { result in - switch result { - case .success(_): - self.pushChannelId = pushChannelId - self.userDeviceToken = deviceToken - self.logger.debug("success registering the push token with Knock") - completion(.success(())) - case .failure(let error): - self.logger.error("Error in registerTokenForAPNS: \(error.localizedDescription)") - completion(.failure(error)) - } - } - } - } - - func isAuthenticated(checkUserToken: Bool = false) -> Bool { - if checkUserToken { - return self.userId?.isEmpty == false && self.api.userToken?.isEmpty == false - } - return self.userId?.isEmpty == false - } - - /** - Sets the user for the current Knock instance. Will also register the device for push notifications if token and channelId are provided. - You can also register the device for push notifications later on by calling Knock.registerTokenForAPNS() - */ - func logout(completion: @escaping (Result) -> Void) { - guard let channelId = self.pushChannelId, let token = self.userDeviceToken else { - self.resetInstance() - completion(.success(())) - return - } - self.unregisterTokenForAPNS(channelId: channelId, token: token) { result in - switch result { - case .success(_): - self.resetInstance() - completion(.success(())) - case .failure(let error): - // Don't reset data if there was an error in the unregistration step. That way the client can retry the logout if they want. - completion(.failure(error)) - } - } - } +internal struct UserService: KnockAPIService, UserServiceProtocol { - func getUser(completionHandler: @escaping ((Result) -> Void)) { - performActionWithUserId( { userId, completion in - self.api.decodeFromGet(User.self, path: "/users/\(userId)", queryItems: nil, then: completion) - }, completionHandler: completionHandler) + internal func getUser(userId: String) async throws -> Knock.User { + try await get(path: "/users/\(userId)", queryItems: nil) } - func updateUser(user: User, completionHandler: @escaping ((Result) -> Void)) { - performActionWithUserId( { userId, completion in - self.api.decodeFromPut(User.self, path: "/users/\(userId)", body: user, then: completion) - }, completionHandler: completionHandler) + internal func updateUser(user: Knock.User) async throws -> Knock.User { + try await put(path: "/users/\(user.id)", body: user) } } diff --git a/Tests/KnockTests/AuthenticationTests.swift b/Tests/KnockTests/AuthenticationTests.swift new file mode 100644 index 0000000..4552e24 --- /dev/null +++ b/Tests/KnockTests/AuthenticationTests.swift @@ -0,0 +1,44 @@ +// +// AuthenticationTests.swift +// +// +// Created by Matt Gardner on 2/2/24. +// + +import XCTest +@testable import Knock + +final class AuthenticationTests: XCTestCase { + + override func setUpWithError() throws { + try? Knock.shared.setup(publishableKey: "pk_123", pushChannelId: "test") + } + + override func tearDownWithError() throws { + Knock.shared = Knock() + } + + + func testSignIn() async throws { + let userName = "testUserName" + let userToken = "testUserToken" + await Knock.shared.signIn(userId: userName, userToken: userToken) + + XCTAssertEqual(userName, Knock.shared.environment.userId) + XCTAssertEqual(userToken, Knock.shared.environment.userToken) + } + + func testSignOut() async throws { + await Knock.shared.signIn(userId: "testUserName", userToken: "testUserToken") + Knock.shared.environment.userDevicePushToken = "test" + Knock.shared.environment.userDevicePushToken = "test" + Knock.shared.environment.userDevicePushToken = "test" + + Knock.shared.authenticationModule.clearDataForSignOut() + + XCTAssertEqual(Knock.shared.environment.userId, nil) + XCTAssertEqual(Knock.shared.environment.userToken, nil) + XCTAssertEqual(Knock.shared.environment.publishableKey, "pk_123") + XCTAssertEqual(Knock.shared.environment.userDevicePushToken, "test") + } +} diff --git a/Tests/KnockTests/ChannelTests.swift b/Tests/KnockTests/ChannelTests.swift new file mode 100644 index 0000000..6dc527b --- /dev/null +++ b/Tests/KnockTests/ChannelTests.swift @@ -0,0 +1,21 @@ +// +// ChannelTests.swift +// +// +// Created by Matt Gardner on 2/5/24. +// + +import XCTest +@testable import Knock + +final class ChannelTests: XCTestCase { + + override func setUpWithError() throws { + try? Knock.shared.setup(publishableKey: "pk_123", pushChannelId: "test") + } + + override func tearDownWithError() throws { + Knock.shared = Knock() + } + +} diff --git a/Tests/KnockTests/KnockTests.swift b/Tests/KnockTests/KnockTests.swift new file mode 100644 index 0000000..b6bc977 --- /dev/null +++ b/Tests/KnockTests/KnockTests.swift @@ -0,0 +1,56 @@ +// +// KnockTests.swift +// KnockTests +// +// Created by Diego on 19/06/23. +// + +import XCTest +@testable import Knock + +final class KnockTests: XCTestCase { + override func setUpWithError() throws { + try? Knock.shared.setup(publishableKey: "pk_123", pushChannelId: "test") + } + + override func tearDownWithError() throws { + Knock.shared = Knock() + } + + func testPublishableKeyError() throws { + do { + let _ = try Knock.shared.setup(publishableKey: "sk_123", pushChannelId: nil) + XCTFail("Expected function to throw an error, but it did not.") + } catch let error as Knock.KnockError { + XCTAssertEqual(error, Knock.KnockError.wrongKeyError, "The error should be wrongKeyError") + } catch { + XCTFail("Expected KnockError, but received a different error.") + } + } + + func testMakingNetworkRequestBeforeKnockSetUp() async { + try! tearDownWithError() + Knock.shared.environment.setUserInfo(userId: "test", userToken: nil) + do { + let _ = try await Knock.shared.getUser() + XCTFail("Expected function to throw an error, but it did not.") + } catch let error as Knock.KnockError { + XCTAssertEqual(error, Knock.KnockError.knockNotSetup, "The error should be knockNotSetup") + } catch { + XCTFail("Expected KnockError, but received a different error.") + } + } + + func testMakingNetworkRequestBeforeSignIn() async { + do { + let _ = try await Knock.shared.getUser() + XCTFail("Expected function to throw an error, but it did not.") + } catch let error as Knock.KnockError { + XCTAssertEqual(error, Knock.KnockError.userIdNotSetError, "The error should be userIdNotSetError") + } catch { + XCTFail("Expected KnockError, but received a different error.") + } + } + + +} diff --git a/KnockTests/KnockTests.swift b/Tests/KnockTests/UserTests.swift similarity index 55% rename from KnockTests/KnockTests.swift rename to Tests/KnockTests/UserTests.swift index b047201..8c55a5c 100644 --- a/KnockTests/KnockTests.swift +++ b/Tests/KnockTests/UserTests.swift @@ -1,35 +1,26 @@ // -// KnockTests.swift -// KnockTests +// UserTests.swift // -// Created by Diego on 19/06/23. +// +// Created by Matt Gardner on 1/31/24. // import XCTest @testable import Knock -final class KnockTests: XCTestCase { - +final class UserTests: XCTestCase { + override func setUpWithError() throws { - // Put setup code here. This method is called before the invocation of each test method in the class. + try? Knock.shared.setup(publishableKey: "pk_123", pushChannelId: "test") } override func tearDownWithError() throws { // Put teardown code here. This method is called after the invocation of each test method in the class. } + - func testExample() throws { - // This is an example of a functional test case. - // Use XCTAssert and related functions to verify your tests produce the correct results. - // Any test you write for XCTest can be annotated as throws and async. - // Mark your test throws to produce an unexpected failure when your test encounters an uncaught error. - // Mark your test async to allow awaiting for asynchronous code to complete. Check the results with assertions afterwards. - - // check that initializing the client with a secret key prefix throws - XCTAssertThrowsError(try Knock(publishableKey: "sk_123", userId: "")) - } - func testUserDecoding1() throws { + func testUserDecoding() throws { let decoder = JSONDecoder() let formatter = DateFormatter() @@ -68,13 +59,4 @@ final class KnockTests: XCTestCase { XCTAssertTrue(reencodedString.contains("extra2")) XCTAssertTrue(reencodedString.contains("a1")) } - - func testPerformanceExample() throws { - // This is an example of a performance test case. - self.measure { - // Put the code you want to measure the time of here. - let _ = try! Knock(publishableKey: "pk_123", userId: "u-123") - } - } - }