diff --git a/Knock.podspec b/Knock.podspec index fd9b54c..d7d2b38 100644 --- a/Knock.podspec +++ b/Knock.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |spec| spec.name = "Knock" - spec.version = "0.2.0" + spec.version = "1.0.0" spec.summary = "An SDK to build in-app notifications experiences in Swift with Knock.." spec.description = <<-DESC @@ -16,6 +16,6 @@ Pod::Spec.new do |spec| spec.author = { "Knock" => "support@knock.app" } spec.source = { :git => "https://github.com/knocklabs/knock-swift.git", :tag => "#{spec.version}" } spec.ios.deployment_target = '16.0' - spec.swift_version = '5.0' + spec.swift_version = '5.3' spec.source_files = "Sources/**/*" end diff --git a/README.md b/README.md index f7f8c61..b7d030e 100644 --- a/README.md +++ b/README.md @@ -1,32 +1,28 @@ -# Swift SDK - -## Features - -* Preferences - * getAllUserPreferences - * getUserPreferences - * setUserPreferences -* Channels - * registerTokenForAPNS - * getUserChannelData - * updateUserChannelData -* Messages - * getMessage - * updateMessageStatus - * deleteMessageStatus - * batchUpdateStatuses -* Users - * getUser - * updateUser -## Installation +# Offical Knock iOS SDK + +[![GitHub Release](https://img.shields.io/github/v/release/knocklabs/knock-swift?style=flat)](https://github.com/knocklabs/knock-swift/releases/latest) +[![CocoaPods](https://img.shields.io/cocoapods/v/Knock.svg?style=flat)](https://cocoapods.org/) +[![Carthage compatible](https://img.shields.io/badge/Carthage-compatible-4BC51D.svg?style=flat)](https://github.com/Carthage/Carthage) +[![Swift Package Manager compatible](https://img.shields.io/badge/Swift%20Package%20Manager-compatible-4BC51D.svg?style=flat)](https://swift.org/package-manager/) + +![min swift version is 5.3](https://img.shields.io/badge/min%20Swift%20version-5.3-orange) +![min ios version is 16](https://img.shields.io/badge/min%20iOS%20version-16-blue) +[![GitHub license](https://img.shields.io/badge/license-MIT-lightgrey.svg?style=flat)](https://github.com/knocklabs/ios-example-app/blob/main/LICENSE) + + + +--- + +Knock is a flexible, reliable notifications infrastructure that's built to scale with you. Use our iOS SDK to engage users with in-app feeds, setup push notifications, and manage notification preferences. -You can include the SDK in a couple of ways: +--- -1. Swift Package Manager -2. Carthage -3. Cocoapods -4. Manually +## Documentation + +See the [documentation](https://docs.knock.app/notification-feeds/bring-your-own-ui) for usage examples. + +## Installation ### Swift Package Manager @@ -42,59 +38,20 @@ There are two ways to add this as a dependency using the Swift Package Manager: Screenshot 2023-06-27 at 19 41 32 2. Search for `https://github.com/knocklabs/knock-swift.git` and then click `Add Package` +*Note: We recommend that you set the Dependency Rule to Up to Next Major Version. While we encourage you to keep your app up to date with the latest SDK, major versions can include breaking changes or new features that require your attention.* Screenshot 2023-06-27 at 19 42 09 -3. Ensure that the Package is selected and click `Add Package` - -Screenshot 2023-06-27 at 19 42 23 - -4. Wait for Xcode to fetch the dependencies and you should see the SDK on your Package Dependencies on the sidebar - -Screenshot 2023-06-27 at 19 42 45 - #### Manually via `Package.swift` If you are managing dependencies using the `Package.swift` file, just add this to you dependencies array: ``` swift dependencies: [ - .package(url: "https://github.com/knocklabs/knock-swift.git", .upToNextMajor(from: "0.2.0")) + .package(url: "https://github.com/knocklabs/knock-swift.git", .upToNextMajor(from: "1.0.0")) ] ``` -### Carthage - -1. Add this line to your Cartfile: - -``` -github "knocklabs/knock-swift" ~> 0.2.0 -``` - -2. Run `carthage update`. This will fetch dependencies into a Carthage/Checkouts folder, then build each one or download a pre-compiled framework. -3. Open your application targets’ General settings tab. For Xcode 11.0 and higher, in the "Frameworks, Libraries, and Embedded Content" section, drag and drop each framework you want to use from the Carthage/Build folder on disk. Then, in the "Embed" section, select "Do Not Embed" from the pulldown menu for each item added. For Xcode 10.x and lower, in the "Linked Frameworks and Libraries" section, drag and drop each framework you want to use from the Carthage/Build folder on disk. -4. On your application targets’ Build Phases settings tab, click the + icon and choose New Run Script Phase. Create a Run Script in which you specify your shell (ex: /bin/sh), add the following contents to the script area below the shell: -``` -/usr/local/bin/carthage copy-frameworks -``` -5. Create a file named `input.xcfilelist` and a file named output.xcfilelist -6. Add the paths to the frameworks you want to use to your input.xcfilelist. For example: -``` -$(SRCROOT)/Carthage/Build/iOS/Knock.framework -``` -7. Add the paths to the copied frameworks to the `output.xcfilelist`. For example: -``` -$(BUILT_PRODUCTS_DIR)/$(FRAMEWORKS_FOLDER_PATH)/Result.framework -``` -8. Add the `input.xcfilelist` to the "Input File Lists" section of the Carthage run script phase -9. Add the `output.xcfilelist` to the "Output File Lists" section of the Carthage run script phase - -This script works around an [App Store submission bug](http://www.openradar.me/radar?id=6409498411401216) triggered by universal binaries and ensures that necessary bitcode-related files and dSYMs are copied when archiving. - -With the debug information copied into the built products directory, Xcode will be able to symbolicate the stack trace whenever you stop at a breakpoint. This will also enable you to step through third-party code in the debugger. - -When archiving your application for submission to the App Store or TestFlight, Xcode will also copy these files into the dSYMs subdirectory of your application’s .xcarchive bundle. - ### Cocoapods Add the dependency to your `Podfile`: @@ -108,6 +65,14 @@ target 'MyApp' do end ``` +### Carthage + +1. Add this line to your Cartfile: + +``` +github "knocklabs/knock-swift" ~> 0.2.0 +``` + ### Manually As a last option, you could manually copy the files inside the `Sources` folder to your project. @@ -119,47 +84,20 @@ You can now start using the SDK: ``` swift import Knock -knockClient = try! Knock(publishableKey: "your-pk", userId: "user-id") - -knockClient.getUser{ result in - switch result { - case .success(let user): - print(user) - case .failure(let error): - print(error.localizedDescription) - } -} -``` +// Setup the shared Knock instance as soon as you can. +try? Knock.shared.setup(publishableKey: "your-pk", pushChannelId: "user-id") -## Using the SDK +// Once you know the Knock UserId, sign the user into the shared Knock instance. +await Knock.shared.signIn(userId: "userid", userToken: nil) -The functions of the sdk are encapsulated and managed in a client object. You first have to instantiate a client with your public key and a user id. If you are running on production with enhanced security turned on (recommended) you have to also pass the signed user token to the client constructor. - -``` swift -import Knock - -knockClient = try! Knock(publishableKey: "your-pk", userId: "user-id") - -// on prod with enhanced security turned on: -knockClient = try! Knock(publishableKey: "your-pk", userId: "user-id", userToken: "signed-user-token") ``` -## Notes for publishing - -When releasing a new version of this SDK, please note: - -* You should update the version in a couple of places: - * in the file `Sources/KnockAPI.swift`: `clientVersion = "..."` - * in the file `Knock.podspec`: `spec.version = "..."` - * in this `README.md`, in the installation instructions for all the package managers - * in git, add a tag, preferably to the commit that includes this previous changes - - - - - - +## How to Contribute +Community contributions are welcome! If you'd like to contribute, please read our [contribution guide](CONTRIBUTING.md). +## License +This project is licensed under the MIT license. +See [LICENSE](LICENSE) for more information. diff --git a/Sources/Knock.swift b/Sources/Knock.swift index 6ebbbf1..a656be9 100644 --- a/Sources/Knock.swift +++ b/Sources/Knock.swift @@ -28,16 +28,30 @@ public class Knock { Returns a new instance of the Knock Client - Parameters: - - publishableKey: your public API key + - publishableKey: Your public API key - options: [optional] Options for customizing the Knock instance. */ + public func setup(publishableKey: String, pushChannelId: String?, options: Knock.KnockStartupOptions? = nil) async throws { + logger.loggingDebugOptions = options?.loggingOptions ?? .errorsOnly + try await environment.setPublishableKey(key: publishableKey) + await environment.setBaseUrl(baseUrl: options?.hostname) + await environment.setPushChannelId(pushChannelId) + } + + @available(*, deprecated, message: "Use async setup() method instead for safer handling.") public func setup(publishableKey: String, pushChannelId: String?, options: Knock.KnockStartupOptions? = nil) throws { - logger.loggingDebugOptions = options?.debuggingType ?? .errorsOnly - try environment.setPublishableKey(key: publishableKey) - environment.setBaseUrl(baseUrl: options?.hostname) - environment.pushChannelId = pushChannelId + logger.loggingDebugOptions = options?.loggingOptions ?? .errorsOnly + Task { + try await environment.setPublishableKey(key: publishableKey) + await environment.setBaseUrl(baseUrl: options?.hostname) + await environment.setPushChannelId(pushChannelId) + } } + /** + Reset the current Knock instance entirely. + After calling this, you will need to setup and signin again. + */ public func resetInstanceCompletely() { Knock.shared = Knock() } @@ -45,27 +59,18 @@ public class Knock { public extension Knock { struct KnockStartupOptions { - public init(hostname: String? = nil, debuggingType: DebugOptions = .errorsOnly) { + public init(hostname: String? = nil, loggingOptions: LoggingOptions = .errorsOnly) { self.hostname = hostname - self.debuggingType = debuggingType + self.loggingOptions = loggingOptions } var hostname: String? - var debuggingType: DebugOptions + var loggingOptions: LoggingOptions } - enum DebugOptions { + enum LoggingOptions { case errorsOnly + case errorsAndWarningsOnly case verbose case none } } - -public extension Knock { - var userId: String? { - get { return environment.userId } - } - - var apnsDeviceToken: String? { - get { return environment.userId } - } -} diff --git a/Sources/KnockAPIService.swift b/Sources/KnockAPIService.swift index 6ba2f8f..8eb31f5 100644 --- a/Sources/KnockAPIService.swift +++ b/Sources/KnockAPIService.swift @@ -17,18 +17,22 @@ internal protocol KnockAPIService { } extension KnockAPIService { - private var apiBaseUrl: String { - return "\(Knock.shared.environment.baseUrl)/v1" + + func apiBaseUrl() async -> String { + let base = await Knock.shared.environment.getBaseUrl() + return "\(base)/v1" } func makeRequest(method: String, path: String, queryItems: [URLQueryItem]?, body: Encodable?) async throws -> T { let sessionConfig = URLSessionConfiguration.default let session = URLSession(configuration: sessionConfig, delegate: nil, delegateQueue: nil) - let loggingMessageSummary = "\(method) \(apiBaseUrl)\(path)" + let baseUrl = await apiBaseUrl() + + let loggingMessageSummary = "\(method) \(baseUrl)\(path)" - guard var URL = URL(string: "\(apiBaseUrl)\(path)") else { - let networkError = Knock.NetworkError(title: "Invalid URL", description: "The URL: \(apiBaseUrl)\(path) is invalid", code: 0) + guard var URL = URL(string: "\(baseUrl)\(path)") else { + let networkError = Knock.NetworkError(title: "Invalid URL", description: "The URL: \(baseUrl)\(path) is invalid", code: 0) Knock.shared.log(type: .warning, category: .networking, message: loggingMessageSummary, status: .fail, errorMessage: networkError.localizedDescription) throw networkError } @@ -52,8 +56,10 @@ extension KnockAPIService { request.addValue("knock-swift@\(Knock.clientVersion)", forHTTPHeaderField: "User-Agent") - request.addValue("Bearer \(try Knock.shared.environment.getSafePublishableKey())", forHTTPHeaderField: "Authorization") - if let userToken = Knock.shared.environment.userToken { + let publishableKey = try await Knock.shared.environment.getSafePublishableKey() + request.addValue("Bearer \(publishableKey)", forHTTPHeaderField: "Authorization") + + if let userToken = await Knock.shared.environment.getUserToken() { request.addValue(userToken, forHTTPHeaderField: "X-Knock-User-Token") } diff --git a/Sources/KnockAppDelegate.swift b/Sources/KnockAppDelegate.swift index 47a3df4..2bd7f0d 100644 --- a/Sources/KnockAppDelegate.swift +++ b/Sources/KnockAppDelegate.swift @@ -9,21 +9,33 @@ import Foundation import UIKit import OSLog +/** +This class serves as an optional base class designed to streamline the integration of Knock into your application. By inheriting from KnockAppDelegate in your AppDelegate, you gain automatic handling of Push Notification registration and device token management, simplifying the initial setup process for Knock's functionalities. + +The class also provides a set of open helper functions that are intended to facilitate the handling of different Push Notification events such as delivery in the foreground, taps, and dismissals. These helper methods offer a straightforward approach to customizing your app's response to notifications, ensuring that you can tailor the behavior to fit your specific needs. + +Override any of the provided methods to achieve further customization, allowing you to control how your application processes and reacts to Push Notifications. Additionally, by leveraging this class, you ensure that your app adheres to best practices for managing device tokens and interacting with the notification system on iOS, enhancing the overall reliability and user experience of your app's notification features. + +Key Features: +- Automatic registration for remote notifications, ensuring your app is promptly set up to receive and handle Push Notifications. +- Simplified device token management, with automatic storage of the device token, facilitating easier access and use in Push Notification payloads. +- Customizable notification handling through open helper functions, allowing for bespoke responses to notification events such as foreground delivery, user taps, and dismissal actions. +- Automatic message status updates, based on Push Notification interaction. + +Developers can benefit from a quick and efficient setup, focusing more on the unique aspects of their notification handling logic while relying on KnockAppDelegate for the foundational setup and management tasks. +*/ + @available(iOSApplicationExtension, unavailable) open class KnockAppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterDelegate { - - // MARK: Init - - public override init() { - super.init() - UIApplication.shared.registerForRemoteNotifications() - UNUserNotificationCenter.current().delegate = self - } + // MARK: Launching - + /// - NOTE: If overriding this function in your AppDelegate, make sure to call super on this to get the default functionality as well. open func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool { - // Check if launched from a notification + UNUserNotificationCenter.current().delegate = self + Knock.shared.requestAndRegisterForPushNotifications() + + // Check if launched from the tap of a notification if let launchOptions = launchOptions, let userInfo = launchOptions[.remoteNotification] as? [String: AnyObject] { Knock.shared.log(type: .error, category: .pushNotification, message: "pushNotificationTapped") @@ -41,14 +53,10 @@ open class KnockAppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificat open func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { Task { - let channelId = Knock.shared.environment.pushChannelId + let channelId = await Knock.shared.environment.getPushChannelId() do { - if let id = channelId { - let _ = try await Knock.shared.registerTokenForAPNS(channelId: id, token: deviceToken) - } else { - Knock.shared.log(type: .error, category: .pushNotification, message: "didRegisterForRemoteNotificationsWithDeviceToken", status: .fail, errorMessage: "Unable to find pushChannelId. Please set the pushChannelId with Knock.shared.setup") - } + let _ = try await Knock.shared.channelModule.registerTokenForAPNS(channelId: channelId, token: Knock.convertTokenToString(token: deviceToken)) } catch let error { Knock.shared.log(type: .error, category: .pushNotification, message: "didRegisterForRemoteNotificationsWithDeviceToken", description: "Unable to register for push notification at this time", status: .fail, errorMessage: error.localizedDescription) } @@ -74,14 +82,30 @@ open class KnockAppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificat pushNotificationDeliveredSilently(userInfo: userInfo, completionHandler: completionHandler) } - // MARK: Convenience methods to make handling incoming push notifications simpler. + // MARK: Helper Functions + /** + Override these functions in your own AppDelegate to simplify push notification handling. + If you want to retain the default logic we provide in these methods, be sure to call the super first. + */ + + public func getMessageId(userInfo: [AnyHashable : Any]) -> String? { + return userInfo["knock_message_id"] as? String + } + open func deviceTokenDidChange(apnsToken: String) {} open func pushNotificationDeliveredInForeground(notification: UNNotification) -> UNNotificationPresentationOptions { + if let messageId = getMessageId(userInfo: notification.request.content.userInfo) { + Knock.shared.updateMessageStatus(messageId: messageId, status: .read) { _ in } + } return [.sound, .badge, .banner] } - open func pushNotificationTapped(userInfo: [AnyHashable : Any]) {} + open func pushNotificationTapped(userInfo: [AnyHashable : Any]) { + if let messageId = getMessageId(userInfo: userInfo) { + Knock.shared.updateMessageStatus(messageId: messageId, status: .interacted) { _ in } + } + } open func pushNotificationDeliveredSilently(userInfo: [AnyHashable : Any], completionHandler: @escaping (UIBackgroundFetchResult) -> Void) { completionHandler(.noData) diff --git a/Sources/KnockEnvironment.swift b/Sources/KnockEnvironment.swift index dbdd0bc..237d849 100644 --- a/Sources/KnockEnvironment.swift +++ b/Sources/KnockEnvironment.swift @@ -7,51 +7,38 @@ import Foundation -internal class KnockEnvironment { +internal actor KnockEnvironment { static let defaultBaseUrl: String = "https://api.knock.app" + private let defaults = UserDefaults.standard private let userDevicePushTokenKey = "knock_push_device_token" - private let pushChannelIdKey = "knock_push_channel_id" + private let previousPushTokensKey = "knock_previous_push_token" - private(set) var userId: String? - private(set) var userToken: String? - private(set) var publishableKey: String? - private(set) var baseUrl: String = defaultBaseUrl - - var userDevicePushToken: String? { - get { - defaults.string(forKey: userDevicePushTokenKey) - } - set { - defaults.set(newValue, forKey: userDevicePushTokenKey) - } - } + private var userId: String? + private var userToken: String? + private var publishableKey: String? + private var pushChannelId: String? + private var baseUrl: String = defaultBaseUrl - var pushChannelId: String? { - get { - defaults.string(forKey: pushChannelIdKey) - } - set { - defaults.set(newValue, forKey: pushChannelIdKey) - } + // BaseURL + + func getBaseUrl() -> String { + baseUrl } - - func setPublishableKey(key: String) throws { - guard key.hasPrefix("sk_") == false else { - let error = Knock.KnockError.wrongKeyError - Knock.shared.log(type: .error, category: .general, message: "setPublishableKey", status: .fail, errorMessage: error.localizedDescription) - throw error - } - self.publishableKey = key + + func setBaseUrl(baseUrl: String?) { + self.baseUrl = "\(baseUrl ?? KnockEnvironment.defaultBaseUrl)" } + //UserId + func setUserInfo(userId: String?, userToken: String?) { self.userId = userId self.userToken = userToken } - func setBaseUrl(baseUrl: String?) { - self.baseUrl = "\(baseUrl ?? KnockEnvironment.defaultBaseUrl)" + func getUserId() -> String? { + userId } func getSafeUserId() throws -> String { @@ -61,10 +48,99 @@ internal class KnockEnvironment { return id } + func getUserToken() -> String? { + userToken + } + + func getSafeUserToken() throws -> String? { + guard let token = userToken else { + throw Knock.KnockError.userTokenNotSet + } + return token + } + + // Publishable Key + func setPublishableKey(key: String) throws { + guard key.hasPrefix("sk_") == false else { + let error = Knock.KnockError.wrongKeyError + Knock.shared.log(type: .error, category: .general, message: "setPublishableKey", status: .fail, errorMessage: error.localizedDescription) + throw error + } + self.publishableKey = key + } + + func getPublishableKey() -> String? { + publishableKey + } + func getSafePublishableKey() throws -> String { guard let id = publishableKey else { throw Knock.KnockError.knockNotSetup } return id } + + // PushChannelId + func setPushChannelId(_ newChannelId: String?) { + self.pushChannelId = newChannelId + } + + func getPushChannelId() -> String? { + self.pushChannelId + } + + func getSafePushChannelId() throws -> String { + guard let id = pushChannelId else { + throw Knock.KnockError.pushChannelIdNotSetError + } + return id + } + + // APNS Device Token + +// public func setDeviceToken(_ token: String?) async { +// let currentToken = getDeviceToken() +// if currentToken != token { +// var previousTokens = await getPreviousPushTokens() +// var tokenSet: Set = Set(previousTokens) +// if let currentToken = currentToken { +// tokenSet.insert(currentToken) +// } +// if let token = token { +// tokenSet.insert(token) +// } +// setPreviousPushTokens(tokens: Array(tokenSet)) +// defaults.set(token, forKey: userDevicePushTokenKey) +// } +// } + + public func setDeviceToken(_ token: String?) async { + let previousTokens = getPreviousPushTokens() + if let token = token, !previousTokens.contains(token) { + // Append new token to the list of previous tokens only if it's unique + // We are storing these old tokens so that we can ensure they get unregestired. + setPreviousPushTokens(tokens: previousTokens + [token]) + } + + // Update the current device token + defaults.set(token, forKey: userDevicePushTokenKey) + } + + func getDeviceToken() -> String? { + defaults.string(forKey: userDevicePushTokenKey) + } + + func getSafeDeviceToken() throws -> String { + guard let token = getDeviceToken() else { + throw Knock.KnockError.devicePushTokenNotSet + } + return token + } + + func setPreviousPushTokens(tokens: [String]) { + defaults.set(tokens, forKey: previousPushTokensKey) + } + func getPreviousPushTokens() -> [String] { + defaults.array(forKey: previousPushTokensKey) as? [String] ?? [] + } } diff --git a/Sources/KnockErrors.swift b/Sources/KnockErrors.swift index cb06e1b..36890a2 100644 --- a/Sources/KnockErrors.swift +++ b/Sources/KnockErrors.swift @@ -11,6 +11,9 @@ public extension Knock { enum KnockError: Error, Equatable { case runtimeError(String) case userIdNotSetError + case userTokenNotSet + case devicePushTokenNotSet + case pushChannelIdNotSetError case knockNotSetup case wrongKeyError } @@ -37,7 +40,13 @@ extension Knock.KnockError: LocalizedError { case .runtimeError(let message): return message case .userIdNotSetError: - return "UserId not found. Please authenticate your userId with Knock.signIn()." + return "UserId not found. Please authenticate your userId with Knock.shared.signIn()." + case .userTokenNotSet: + return "User token must be set for production environments. Please authenticate your user toekn with Knock.shared.signIn()." + case .pushChannelIdNotSetError: + return "PushChannelId not found. Please setup with Knock.shared.setup() or Knock.shared.registerTokenForAPNS()." + case .devicePushTokenNotSet: + return "Device Push Notification token not found. Please setup with Knock.shared.registerTokenForAPNS()." case .knockNotSetup: return "Knock instance still needs to be setup. Please setup with Knock.shared.setup()." case .wrongKeyError: diff --git a/Sources/KnockLogger.swift b/Sources/KnockLogger.swift index 5f5ea72..b9df9c6 100644 --- a/Sources/KnockLogger.swift +++ b/Sources/KnockLogger.swift @@ -11,7 +11,7 @@ import os.log internal class KnockLogger { private static let loggingSubsytem = "knock-swift" - internal var loggingDebugOptions: Knock.DebugOptions = .errorsOnly + internal var loggingDebugOptions: Knock.LoggingOptions = .errorsOnly internal func log(type: LogType, category: LogCategory, message: String, description: String? = nil, status: LogStatus? = nil, errorMessage: String? = nil, additionalInfo: [String: String]? = nil) { switch loggingDebugOptions { @@ -19,6 +19,10 @@ internal class KnockLogger { if type != .error { return } + case .errorsAndWarningsOnly: + if type != .error || type != .warning { + return + } case .verbose: break case .none: diff --git a/Sources/Modules/Authentication/AuthenticationModule.swift b/Sources/Modules/Authentication/AuthenticationModule.swift index 3f922a6..c5dac1e 100644 --- a/Sources/Modules/Authentication/AuthenticationModule.swift +++ b/Sources/Modules/Authentication/AuthenticationModule.swift @@ -8,11 +8,11 @@ import Foundation internal class AuthenticationModule { - + func signIn(userId: String, userToken: String?) async { - Knock.shared.environment.setUserInfo(userId: userId, userToken: userToken) + await Knock.shared.environment.setUserInfo(userId: userId, userToken: userToken) - if let token = Knock.shared.environment.userDevicePushToken, let channelId = Knock.shared.environment.pushChannelId { + if let token = await Knock.shared.environment.getDeviceToken(), let channelId = await Knock.shared.environment.getPushChannelId() { do { let _ = try await Knock.shared.channelModule.registerTokenForAPNS(channelId: channelId, token: token) } catch { @@ -24,35 +24,49 @@ internal class AuthenticationModule { } func signOut() async throws { - guard let channelId = Knock.shared.environment.pushChannelId, let token = Knock.shared.environment.userDevicePushToken else { - clearDataForSignOut() + guard let channelId = await Knock.shared.environment.getPushChannelId(), let token = await Knock.shared.environment.getDeviceToken() else { + await clearDataForSignOut() return } let _ = try await Knock.shared.channelModule.unregisterTokenForAPNS(channelId: channelId, token: token) - clearDataForSignOut() + await clearDataForSignOut() return } - func clearDataForSignOut() { - Knock.shared.environment.setUserInfo(userId: nil, userToken: nil) + func clearDataForSignOut() async { + await Knock.shared.environment.setUserInfo(userId: nil, userToken: nil) } } public extension Knock { - - func isAuthenticated(checkUserToken: Bool = false) -> Bool { - let isUser = Knock.shared.environment.userId?.isEmpty == false + /** + Convienience method to determine if a user is currently authenticated for the Knock instance. + */ + func isAuthenticated(checkUserToken: Bool = false) async -> Bool { + let isUser = await Knock.shared.environment.getUserId()?.isEmpty == false if checkUserToken { - return isUser && Knock.shared.environment.userToken?.isEmpty == false + let hasToken = await Knock.shared.environment.getUserToken()?.isEmpty == false + return isUser && hasToken } return isUser } + func isAuthenticated(checkUserToken: Bool = false, completionHandler: @escaping ((Bool) -> Void)) { + Task { + completionHandler(await isAuthenticated(checkUserToken: checkUserToken)) + } + } + /** - Set the current credentials for the user and their access token. - Will also registerAPNS device token if set previously. - You should consider using this in areas where you update your local user's state + Sets the userId and userToken for the current Knock instance. + If the device token and pushChannelId were set previously, this will also attempt to register the token to the user that is being signed in. + This does not get the user from the database nor does it return the full User object. + You should consider using this in areas where you update your local user's state. + + - Parameters: + - userId: The id of the Knock channel to lookup. + - userToken: [optional] The id of the Knock channel to lookup. */ func signIn(userId: String, userToken: String?) async { await authenticationModule.signIn(userId: userId, userToken: userToken) @@ -66,9 +80,10 @@ public extension Knock { } /** - Clears the current user id and access token + Sets the userId and userToken for the current Knock instance back to nil. + If the device token and pushChannelId were set previously, this will also attempt to unregister the token to the user that is being signed out so they don't receive pushes they shouldn't get. You should call this when your user signs out - It will remove the current tokens used for this user in Courier so they do not receive pushes they should not get + - Note: This will not clear the device token so that it can be accesed for the next user to login. */ func signOut() async throws { try await authenticationModule.signOut() diff --git a/Sources/Modules/Channels/ChannelModule.swift b/Sources/Modules/Channels/ChannelModule.swift index 917a026..7f3694b 100644 --- a/Sources/Modules/Channels/ChannelModule.swift +++ b/Sources/Modules/Channels/ChannelModule.swift @@ -38,72 +38,130 @@ internal class ChannelModule { } } - private func registerOrUpdateToken(token: String, channelId: String, existingTokens: [String]?) async throws -> Knock.ChannelData { - var tokens = existingTokens ?? [] - if !tokens.contains(token) { - tokens.append(token) + // MARK: APNS Device Token Registration + + func registerTokenForAPNS(channelId: String?, token: String) async throws -> Knock.ChannelData { + // Store or update the token locally immediately + await Knock.shared.environment.setDeviceToken(token) + + // Ensure user is authenticated + guard let channelId = channelId, await Knock.shared.isAuthenticated() else { + // Exit if not authenticated; token is stored for later registration + Knock.shared.log(type: .warning, category: .pushNotification, message: "ChannelId and deviceToken were saved. However, we cannot register for APNS until you have have called Knock.signIn().") + return .init(channel_id: "", data: ["tokens": [token]]) } - let data: AnyEncodable = ["tokens": tokens] - let channelData = try await updateUserChannelData(channelId: channelId, data: data) - Knock.shared.log(type: .debug, category: .pushNotification, message: "registerOrUpdateToken", status: .success) - return channelData + await Knock.shared.environment.setPushChannelId(channelId) + + // Now proceed to prepare and register the token on the server + return try await prepareToRegisterTokenOnServer(token: token, channelId: channelId) } - func registerTokenForAPNS(channelId: String, token: String) async throws -> Knock.ChannelData { - Knock.shared.environment.pushChannelId = channelId - Knock.shared.environment.userDevicePushToken = token - + private func prepareToRegisterTokenOnServer(token: String, channelId: String) async throws -> Knock.ChannelData { do { - let channelData = try await getUserChannelData(channelId: channelId) - guard let data = channelData.data, let tokens = data["tokens"]?.value as? [String] else { - // No valid tokens array found, register a new one - return try await registerOrUpdateToken(token: token, channelId: channelId, existingTokens: nil) - } + // Retrieve existing channel data to prepare token update + let existingChannelData = try await getUserChannelData(channelId: channelId) + let existingChannelTokens = existingChannelData.data?["tokens"]?.value as? [String] ?? [] - if tokens.contains(token) { - // Token already registered - Knock.shared.log(type: .debug, category: .pushNotification, message: "registerTokenForAPNS", status: .success) - return channelData + // Retrieve old tokens that have not yet been deregistered + let previousTokens = await Knock.shared.environment.getPreviousPushTokens() + + // Filter and prepare tokens for registration + let preparedTokens = getTokenDataForServer( + newToken: token, + previousTokens: previousTokens, + channelDataTokens: existingChannelTokens + ) + + // Proceed with updating the server if there are changes + if preparedTokens != existingChannelTokens { + return try await registerNewTokenDataOnServer(tokens: preparedTokens, channelId: channelId) } else { - // Register the new token - return try await registerOrUpdateToken(token: token, channelId: channelId, existingTokens: tokens) + return existingChannelData } } catch let userIdError as Knock.KnockError where userIdError == Knock.KnockError.userIdNotSetError { + // User is not signed in. This should be caught earlier, but including it here as well just in case. Knock.shared.log(type: .warning, category: .pushNotification, message: "ChannelId and deviceToken were saved. However, we cannot register for APNS until you have have called Knock.signIn().") - throw userIdError - } catch { + return .init(channel_id: channelId, data: ["tokens": [token]]) + } catch let networkError as Knock.NetworkError where networkError.code == 404 { // No data registered on that channel for that user, we'll create a new record - return try await registerOrUpdateToken(token: token, channelId: channelId, existingTokens: nil) + return try await registerNewTokenDataOnServer(tokens: [token], channelId: channelId) + } catch { + Knock.shared.log(type: .error, category: .pushNotification, message: "Failed to register token", errorMessage: error.localizedDescription) + throw error } } + private func registerNewTokenDataOnServer(tokens: [String], channelId: String) async throws -> Knock.ChannelData { + let data: AnyEncodable = ["tokens": tokens] + let newChannelData = try await updateUserChannelData(channelId: channelId, data: data) + + // Clear previous tokens upon successful update + await Knock.shared.environment.setPreviousPushTokens(tokens: []) + + Knock.shared.log(type: .debug, category: .pushNotification, message: "Token registered on server", status: .success) + return newChannelData + } + + internal func getTokenDataForServer( + newToken: String, + previousTokens: [String], + channelDataTokens: [String], + forDeregistration: Bool = false + ) -> [String] { + var updatedTokens = channelDataTokens + + // Filter out any tokens from existingTokens that are also in previousTokens + updatedTokens.removeAll(where: { previousTokens.contains($0) }) + + // Add the new token if it's not already in the list + if forDeregistration { + updatedTokens.removeAll(where: { $0 == newToken }) + } else if !updatedTokens.contains(newToken) { + updatedTokens.append(newToken) + } + + return updatedTokens + } + + + // MARK: APNS Device Token UnRegistration + func unregisterTokenForAPNS(channelId: String, token: String) async throws -> Knock.ChannelData { do { let channelData = try await getUserChannelData(channelId: channelId) - guard let data = channelData.data, let tokens = data["tokens"]?.value as? [String] else { + let previousTokens = await Knock.shared.environment.getPreviousPushTokens() + + guard let tokens = channelData.data?["tokens"]?.value as? [String] else { // No valid tokens array found. Knock.shared.log(type: .warning, category: .pushNotification, message: "unregisterTokenForAPNS", description: "Could not unregister user from channel \(channelId). Reason: User doesn't have any device tokens associated to the provided channelId.") return channelData } - if tokens.contains(token) { + let updatedTokens = getTokenDataForServer(newToken: token, previousTokens: previousTokens, channelDataTokens: tokens, forDeregistration: true) + + if updatedTokens != tokens { let newTokensSet = Set(tokens).subtracting([token]) let newTokens = Array(newTokensSet) let data: AnyEncodable = [ "tokens": newTokens ] + let updateData = try await updateUserChannelData(channelId: channelId, data: data) + + // Clear previous tokens upon successful update + await Knock.shared.environment.setPreviousPushTokens(tokens: []) + Knock.shared.log(type: .debug, category: .pushNotification, message: "unregisterTokenForAPNS", status: .success) return updateData } else { - Knock.shared.log(type: .warning, category: .pushNotification, message: "unregisterTokenForAPNS", description: "Could not unregister user from channel \(channelId). Reason: User doesn't have any device tokens associated to the provided channelId.") + Knock.shared.log(type: .debug, category: .pushNotification, message: "unregisterTokenForAPNS", description: "Could not unregister user from channel \(channelId). Reason: User doesn't have any device tokens associated to the provided channelId.") return channelData } } catch { if let networkError = error as? Knock.NetworkError, networkError.code == 404 { // No data registered on that channel for that user - Knock.shared.log(type: .warning, category: .pushNotification, message: "unregisterTokenForAPNS", description: "Could not unregister user from channel \(channelId). Reason: User doesn't have any channel data associated to the provided channelId.") + Knock.shared.log(type: .debug, category: .pushNotification, message: "unregisterTokenForAPNS", description: "Could not unregister user from channel \(channelId). Reason: User doesn't have any channel data associated to the provided channelId.") return .init(channel_id: channelId, data: [:]) } else { // Unknown error. Could be network or server related. Try again. @@ -116,6 +174,13 @@ internal class ChannelModule { public extension Knock { + /** + Retrieves the channel data for the current user on the channel specified. + https://docs.knock.app/reference#get-user-channel-data#get-user-channel-data + + - Parameters: + - channelId: The id of the Knock channel to lookup. + */ func getUserChannelData(channelId: String) async throws -> ChannelData { try await self.channelModule.getUserChannelData(channelId: channelId) } @@ -135,7 +200,7 @@ public extension Knock { Sets channel data for the user and the channel specified. - Parameters: - - channelId: the id of the channel + - channelId: The id of the Knock channel to lookup - data: the shape of the payload varies depending on the channel. You can learn more about channel data schemas [here](https://docs.knock.app/send-notifications/setting-channel-data#provider-data-requirements). */ func updateUserChannelData(channelId: String, data: AnyEncodable) async throws -> ChannelData { @@ -156,12 +221,27 @@ public extension Knock { // Mark: Registration of APNS device tokens /** - Registers an Apple Push Notification Service token so that the device can receive remote push notifications. This is a convenience method that internally gets the channel data and searches for the token. If it exists, then it's already registered and it returns. If the data does not exists or the token is missing from the array, it's added. + Returns the apnsDeviceToekn that was set from the Knock.shared.registerTokenForAPNS. + If you use our KnockAppDelegate, the token registration will be handled for you automatically. + */ + func getApnsDeviceToken() async -> String? { + await environment.getDeviceToken() + } + + func getApnsDeviceToken(completion: @escaping (String?) -> Void) { + Task { + completion(await environment.getDeviceToken()) + } + } + + /** + Registers an Apple Push Notification Service token so that the device can receive remote push notifications. + This is a convenience method that internally gets the channel data and searches for the token. If it exists, then it's already registered and it returns. + If the data does not exists or the token is missing from the array, it's added. + If the new token differs from the last token that was used on the device, the old token will be unregistered. You can learn more about APNS [here](https://developer.apple.com/documentation/usernotifications/registering_your_app_with_apns). - - - Attention: There's a race condition because the getting/setting of the token are not made in a transaction. - + - Parameters: - channelId: the id of the APNS channel - token: the APNS device token as a `String` @@ -181,17 +261,6 @@ public extension Knock { } } - /** - Registers an Apple Push Notification Service token so that the device can receive remote push notifications. This is a convenience method that internally gets the channel data and searches for the token. If it exists, then it's already registered and it returns. If the data does not exists or the token is missing from the array, it's added. - - You can learn more about APNS [here](https://developer.apple.com/documentation/usernotifications/registering_your_app_with_apns). - - - Attention: There's a race condition because the getting/setting of the token are not made in a transaction. - - - Parameters: - - channelId: the id of the APNS channel - - token: the APNS device token as `Data` - */ func registerTokenForAPNS(channelId: String, token: Data) async throws -> ChannelData { // 1. Convert device token to string let tokenString = Knock.convertTokenToString(token: token) @@ -212,7 +281,6 @@ public extension Knock { - channelId: the id of the APNS channel in Knock - token: the APNS device token as a `String` */ - func unregisterTokenForAPNS(channelId: String, token: String) async throws -> ChannelData { return try await self.channelModule.unregisterTokenForAPNS(channelId: channelId, token: token) } @@ -240,6 +308,9 @@ public extension Knock { unregisterTokenForAPNS(channelId: channelId, token: tokenString, completionHandler: completionHandler) } + /** + Convenience method to determine whether or not the user is allowing Push Notifications for the app. + */ func getNotificationPermissionStatus(completion: @escaping (UNAuthorizationStatus) -> Void) { channelModule.userNotificationCenter.getNotificationSettings(completionHandler: { settings in completion(settings.authorizationStatus) @@ -251,6 +322,9 @@ public extension Knock { return settings.authorizationStatus } + /** + Convenience method to request Push Notification permissions for the app. + */ func requestNotificationPermission(options: UNAuthorizationOptions = [.sound, .badge, .alert], completion: @escaping (UNAuthorizationStatus) -> Void) { channelModule.userNotificationCenter.requestAuthorization( options: options, @@ -266,4 +340,17 @@ public extension Knock { try await channelModule.userNotificationCenter.requestAuthorization(options: options) return await getNotificationPermissionStatus() } + + /** + Convenience method to request Push Notification permissions for the app, and then, if successfull, registerForRemoteNotifications in order to get a device token. + */ + func requestAndRegisterForPushNotifications() { + Knock.shared.requestNotificationPermission { status in + if status != .denied { + DispatchQueue.main.async { + UIApplication.shared.registerForRemoteNotifications() + } + } + } + } } diff --git a/Sources/Modules/Feed/FeedManager.swift b/Sources/Modules/Feed/FeedManager.swift index 84a56ac..48c8214 100644 --- a/Sources/Modules/Feed/FeedManager.swift +++ b/Sources/Modules/Feed/FeedManager.swift @@ -13,15 +13,22 @@ import UIKit public extension Knock { class FeedManager { - private let feedModule: FeedModule + private var feedModule: FeedModule! private var foregroundObserver: NSObjectProtocol? private var backgroundObserver: NSObjectProtocol? - public init(feedId: String, options: FeedClientOptions = FeedClientOptions(archived: .exclude)) throws { - self.feedModule = try FeedModule(feedId: feedId, options: options) + public init(feedId: String, options: FeedClientOptions = FeedClientOptions(archived: .exclude)) async throws { + self.feedModule = try await FeedModule(feedId: feedId, options: options) registerForAppLifecycleNotifications() } + public init(feedId: String, options: FeedClientOptions = FeedClientOptions(archived: .exclude)) throws { + Task { + self.feedModule = try await FeedModule(feedId: feedId, options: options) + registerForAppLifecycleNotifications() + } + } + deinit { unregisterFromAppLifecycleNotifications() } @@ -45,11 +52,19 @@ public extension Knock { } } + private func didEnterForeground() { + Knock.shared.feedManager?.connectToFeed() + } + + private func didEnterBackground() { + Knock.shared.feedManager?.disconnectFromFeed() + } + /** Connect to the feed via socket. This will initialize the connection. You should also call the `on(eventName, completionHandler)` function to delegate what should be executed on certain received events and the `disconnectFromFeed()` function to terminate the connection. - Parameters: - - options: options of type `FeedClientOptions` to merge with the default ones (set on the constructor) and scope as much as possible the results + - options: [optional] Options of type `FeedClientOptions` to merge with the default ones (set on the constructor) and scope as much as possible the results */ public func connectToFeed(options: FeedClientOptions? = nil) { feedModule.connectToFeed(options: options) @@ -64,11 +79,10 @@ public extension Knock { } /** - Gets the content of the user feed - + Retrieves a feed of items in reverse chronological order + - Parameters: - - options: options of type `FeedClientOptions` to merge with the default ones (set on the constructor) and scope as much as possible the results - - completionHandler: the code to execute when the response is received + - options: [optional] Options of type `FeedClientOptions` to merge with the default ones (set on the constructor) and scope as much as possible the results */ public func getUserFeedContent(options: FeedClientOptions? = nil) async throws -> Feed { try await self.feedModule.getUserFeedContent(options: options) @@ -91,9 +105,8 @@ public extension Knock { - Attention: The base scope for the call should take into account all of the options currently set on the feed, as well as being scoped for the current user. We do this so that we **ONLY** make changes to the messages that are currently in view on this feed, and not all messages that exist. - Parameters: - - type: the kind of update - - options: all the options currently set on the feed to scope as much as possible the bulk update - - completionHandler: the code to execute when the response is received + - type: The kind of update + - options: All the options currently set on the feed to scope as much as possible the bulk update */ public func makeBulkStatusUpdate(type: BulkChannelMessageStatusUpdateType, options: FeedClientOptions) async throws -> BulkOperation { try await feedModule.makeBulkStatusUpdate(type: type, options: options) @@ -109,13 +122,5 @@ public extension Knock { } } } - - public func didEnterForeground() { - Knock.shared.feedManager?.connectToFeed() - } - - public func didEnterBackground() { - Knock.shared.feedManager?.disconnectFromFeed() - } } } diff --git a/Sources/Modules/Feed/FeedModule.swift b/Sources/Modules/Feed/FeedModule.swift index d850403..00dc1dd 100644 --- a/Sources/Modules/Feed/FeedModule.swift +++ b/Sources/Modules/Feed/FeedModule.swift @@ -17,19 +17,22 @@ internal class FeedModule { private var feedOptions: Knock.FeedClientOptions private let feedService = FeedService() - internal init(feedId: String, options: Knock.FeedClientOptions) throws { + internal init(feedId: String, options: Knock.FeedClientOptions) async throws { // use regex and circumflex accent to mark only the starting http to be replaced and not any others - let websocketHostname = Knock.shared.environment.baseUrl.replacingOccurrences(of: "^http", with: "ws", options: .regularExpression) // default: wss://api.knock.app + let base = await Knock.shared.environment.getBaseUrl() + let websocketHostname = base.replacingOccurrences(of: "^http", with: "ws", options: .regularExpression) // default: wss://api.knock.app let websocketPath = "\(websocketHostname)/ws/v1/websocket" // default: wss://api.knock.app/ws/v1/websocket var userId = "" do { - userId = try Knock.shared.environment.getSafeUserId() + userId = try await Knock.shared.environment.getSafeUserId() } catch let error { Knock.shared.log(type: .error, category: .feed, message: "FeedManager", status: .fail, errorMessage: "Must sign user in before initializing the FeedManager") throw error } - self.socket = Socket(websocketPath, params: ["vsn": "2.0.0", "api_key": try Knock.shared.environment.getSafePublishableKey(), "user_token": Knock.shared.environment.userToken ?? ""]) + let userToken = await Knock.shared.environment.getUserToken() + let publishableKey = try await Knock.shared.environment.getSafePublishableKey() + self.socket = Socket(websocketPath, params: ["vsn": "2.0.0", "api_key": publishableKey, "user_token": userToken ?? ""]) self.feedId = feedId self.feedTopic = "feeds:\(feedId):\(userId)" self.feedOptions = options @@ -70,7 +73,7 @@ internal class FeedModule { // delivery_status: one of `queued`, `sent`, `delivered`, `delivery_attempted`, `undelivered`, `not_sent` // engagement_status: one of `seen`, `unseen`, `read`, `unread`, `archived`, `unarchived`, `interacted` // Also check if the parameters sent here are valid - let userId = try Knock.shared.environment.getSafeUserId() + let userId = try await Knock.shared.environment.getSafeUserId() let body: AnyEncodable = [ "user_ids": [userId], "engagement_status": options.status != nil && options.status != .all ? options.status!.rawValue : "", @@ -131,7 +134,6 @@ internal class FeedModule { } } - // TODO: Determine the level of logging we want from SwiftPhoenixClient. Currently this produces a lot of noise. socket.logger = { msg in Knock.shared.log(type: .debug, category: .feed, message: "SwiftPhoenixClient", description: msg) } diff --git a/Sources/Modules/Feed/Models/Feed.swift b/Sources/Modules/Feed/Models/Feed.swift index ff957b8..eab2db5 100644 --- a/Sources/Modules/Feed/Models/Feed.swift +++ b/Sources/Modules/Feed/Models/Feed.swift @@ -9,6 +9,7 @@ import Foundation public extension Knock { + // https://docs.knock.app/reference#get-feed#feeds struct Feed: Codable { public var entries: [FeedItem] = [] public var meta: FeedMetadata = FeedMetadata() diff --git a/Sources/Modules/Feed/Models/FeedItemScope.swift b/Sources/Modules/Feed/Models/FeedItemScope.swift index e3688f6..a5232aa 100644 --- a/Sources/Modules/Feed/Models/FeedItemScope.swift +++ b/Sources/Modules/Feed/Models/FeedItemScope.swift @@ -10,12 +10,9 @@ import Foundation extension Knock { public enum FeedItemScope: String, Codable { - // TODO: check engagement_status in https://docs.knock.app/reference#bulk-update-channel-message-status - // extras: - // case archived - // case unarchived - // case interacted - // minus "all" + case archived + case unarchived + case interacted case all case unread case read diff --git a/Sources/Modules/Messages/MessageModule.swift b/Sources/Modules/Messages/MessageModule.swift index 7b19c88..c339eca 100644 --- a/Sources/Modules/Messages/MessageModule.swift +++ b/Sources/Modules/Messages/MessageModule.swift @@ -57,6 +57,13 @@ internal class MessageModule { public extension Knock { + /** + Returns the KnockMessage for the associated messageId. + https://docs.knock.app/reference#get-a-message + + - Parameters: + - messageId: The messageId for the KnockMessage. + */ func getMessage(messageId: String) async throws -> KnockMessage { try await self.messageModule.getMessage(messageId: messageId) } @@ -72,6 +79,14 @@ public extension Knock { } } + /** + Marks the given message with the provided status, recording an event in the process. + https://docs.knock.app/reference#update-message-status + + - Parameters: + - message: The KnockMessage that you want to update. + - status: The new status to be associated with the KnockMessage. + */ func updateMessageStatus(message: KnockMessage, status: KnockMessageStatusUpdateType) async throws -> KnockMessage { try await self.messageModule.updateMessageStatus(messageId: message.id, status: status) } @@ -87,6 +102,14 @@ public extension Knock { } } + /** + Marks the given message with the provided status, recording an event in the process. + https://docs.knock.app/reference#update-message-status + + - Parameters: + - messageId: The id for the KnockMessage that you want to update. + - status: The new status to be associated with the KnockMessage. + */ func updateMessageStatus(messageId: String, status: KnockMessageStatusUpdateType) async throws -> KnockMessage { try await self.messageModule.updateMessageStatus(messageId: messageId, status: status) } @@ -102,6 +125,14 @@ public extension Knock { } } + /** + Un-marks the given status on a message, recording an event in the process. + https://docs.knock.app/reference#undo-message-status + + - Parameters: + - message: The KnockMessage that you want to update. + - status: The new status to be associated with the KnockMessage. + */ func deleteMessageStatus(message: KnockMessage, status: KnockMessageStatusUpdateType) async throws -> KnockMessage { try await self.messageModule.deleteMessageStatus(messageId: message.id, status: status) } @@ -117,8 +148,16 @@ public extension Knock { } } + /** + Un-marks the given status on a message, recording an event in the process. + https://docs.knock.app/reference#undo-message-status + + - Parameters: + - preferenceId: The preferenceId for the PreferenceSet. + - preferenceSet: PreferenceSet with updated properties. + */ func deleteMessageStatus(messageId: String, status: KnockMessageStatusUpdateType) async throws -> KnockMessage { - try await self.messageModule.updateMessageStatus(messageId: messageId, status: status) + try await self.messageModule.deleteMessageStatus(messageId: messageId, status: status) } func deleteMessageStatus(messageId: String, status: KnockMessageStatusUpdateType, completionHandler: @escaping ((Result) -> Void)) { @@ -135,11 +174,13 @@ public extension Knock { /** Batch status update for a list of messages + https://docs.knock.app/reference#batch-update-message-status - Parameters: - messageIds: the list of message ids: `[String]` to be updated - status: the new `Status` - - completionHandler: the code to execute when the response is received + + *Note:* Knock scopes this batch rate limit by message_ids and status. This allows for 1 update per second per message per status. */ func batchUpdateStatuses(messageIds: [String], status: KnockMessageStatusBatchUpdateType) async throws -> [KnockMessage] { try await self.messageModule.batchUpdateStatuses(messageIds: messageIds, status: status) @@ -158,11 +199,13 @@ public extension Knock { /** Batch status update for a list of messages + https://docs.knock.app/reference#batch-update-message-status - Parameters: - messages: the list of messages `[KnockMessage]` to be updated - status: the new `Status` - - completionHandler: the code to execute when the response is received + + *Note:* Knock scopes this batch rate limit by message_ids and status. This allows for 1 update per second per message per status. */ func batchUpdateStatuses(messages: [KnockMessage], status: KnockMessageStatusBatchUpdateType) async throws -> [KnockMessage] { let messageIds = messages.map{$0.id} diff --git a/Sources/Modules/Messages/Models/KnockMessage.swift b/Sources/Modules/Messages/Models/KnockMessage.swift index 44dafbb..9b7bc04 100644 --- a/Sources/Modules/Messages/Models/KnockMessage.swift +++ b/Sources/Modules/Messages/Models/KnockMessage.swift @@ -8,6 +8,7 @@ import Foundation public extension Knock { + // https://docs.knock.app/reference#messages#feeds // Named `KnockMessage` and not only `Message` to avoid a name colission to the type in `SwiftPhoenixClient` struct KnockMessage: Codable { @@ -37,8 +38,8 @@ public extension Knock { public let interacted_at: Date? public let link_clicked_at: Date? public let archived_at: Date? - // public let inserted_at: Date? // check datetime format, it differs from the others - // public let updated_at: Date? // check datetime format, it differs from the others + public let inserted_at: Date? + public let updated_at: Date? public let source: WorkflowSource public let data: [String: AnyCodable]? // GenericData } diff --git a/Sources/Modules/Messages/Models/KnockMessageStatus.swift b/Sources/Modules/Messages/Models/KnockMessageStatus.swift index 5279b1c..5227f66 100644 --- a/Sources/Modules/Messages/Models/KnockMessageStatus.swift +++ b/Sources/Modules/Messages/Models/KnockMessageStatus.swift @@ -16,12 +16,7 @@ public extension Knock { case delivery_attempted case undelivered case seen -// case read -// case interacted -// case archived case unseen -// case unread -// case unarchived } enum KnockMessageEngagementStatus: String, Codable { diff --git a/Sources/Modules/Preferences/Models/PreferenceSet.swift b/Sources/Modules/Preferences/Models/PreferenceSet.swift new file mode 100644 index 0000000..0c09657 --- /dev/null +++ b/Sources/Modules/Preferences/Models/PreferenceSet.swift @@ -0,0 +1,31 @@ +// +// File.swift +// +// +// Created by Matt Gardner on 2/6/24. +// + +import Foundation + + +public extension Knock { + + //https://docs.knock.app/reference#preferences#preferences + + struct PreferenceSet: Codable { + public var id: String? = nil // default or tenant.id; TODO: check this, because the API allows any value to be used here, not only default and an existing tenant.id + public var channel_types: ChannelTypePreferences = ChannelTypePreferences() + public var workflows: [String: Either] = [:] + public var categories: [String: Either] = [:] + + public init(from decoder: Decoder) throws { + let container: KeyedDecodingContainer = try decoder.container(keyedBy: Knock.PreferenceSet.CodingKeys.self) + self.id = try container.decodeIfPresent(String.self, forKey: Knock.PreferenceSet.CodingKeys.id) + self.channel_types = try container.decodeIfPresent(Knock.ChannelTypePreferences.self, forKey: Knock.PreferenceSet.CodingKeys.channel_types) ?? ChannelTypePreferences() + self.workflows = try container.decodeIfPresent([String : Either].self, forKey: Knock.PreferenceSet.CodingKeys.workflows) ?? [:] + self.categories = try container.decodeIfPresent([String : Either].self, forKey: Knock.PreferenceSet.CodingKeys.categories) ?? [:] + } + + public init() {} + } +} diff --git a/Sources/Modules/Preferences/Models/WorkflowPreference.swift b/Sources/Modules/Preferences/Models/WorkflowPreference.swift index a14109f..f31d9d8 100644 --- a/Sources/Modules/Preferences/Models/WorkflowPreference.swift +++ b/Sources/Modules/Preferences/Models/WorkflowPreference.swift @@ -25,22 +25,7 @@ public extension Knock { } } - struct PreferenceSet: Codable { - public var id: String? = nil // default or tenant.id; TODO: check this, because the API allows any value to be used here, not only default and an existing tenant.id - public var channel_types: ChannelTypePreferences = ChannelTypePreferences() - public var workflows: [String: Either] = [:] - public var categories: [String: Either] = [:] - - public init(from decoder: Decoder) throws { - let container: KeyedDecodingContainer = try decoder.container(keyedBy: Knock.PreferenceSet.CodingKeys.self) - self.id = try container.decodeIfPresent(String.self, forKey: Knock.PreferenceSet.CodingKeys.id) - self.channel_types = try container.decodeIfPresent(Knock.ChannelTypePreferences.self, forKey: Knock.PreferenceSet.CodingKeys.channel_types) ?? ChannelTypePreferences() - self.workflows = try container.decodeIfPresent([String : Either].self, forKey: Knock.PreferenceSet.CodingKeys.workflows) ?? [:] - self.categories = try container.decodeIfPresent([String : Either].self, forKey: Knock.PreferenceSet.CodingKeys.categories) ?? [:] - } - - public init() {} - } + struct WorkflowPreferenceBoolItem: Identifiable, Equatable { public var id: String diff --git a/Sources/Modules/Preferences/PreferenceModule.swift b/Sources/Modules/Preferences/PreferenceModule.swift index 9cc0c1e..b093ed2 100644 --- a/Sources/Modules/Preferences/PreferenceModule.swift +++ b/Sources/Modules/Preferences/PreferenceModule.swift @@ -46,6 +46,12 @@ internal class PreferenceModule { } public extension Knock { + + /** + Retrieve all user's preference sets. Will always return an empty preference set object, even if it does not currently exist for the user. + https://docs.knock.app/reference#get-preferences-user#get-preferences-user + + */ func getAllUserPreferences() async throws -> [Knock.PreferenceSet] { try await self.preferenceModule.getAllUserPreferences() } @@ -61,6 +67,13 @@ public extension Knock { } } + /** + Retrieve a user's preference set. Will always return an empty preference set object, even if it does not currently exist for the user. + https://docs.knock.app/reference#get-preferences-user#get-preferences-user + + - Parameters: + - preferenceId: The preferenceId for the PreferenceSet. + */ func getUserPreferences(preferenceId: String) async throws -> Knock.PreferenceSet { try await self.preferenceModule.getUserPreferences(preferenceId: preferenceId) } @@ -76,6 +89,19 @@ public extension Knock { } } + /** + Sets preferences within the given preference set. This is a destructive operation and will replace any existing preferences with the preferences given. + + If no user exists in the current environment for the current user, Knock will create the user entry as part of this request. + + The preference set :id can be either "default" or a tenant.id. Learn more about per-tenant preference sets in our preferences guide. + https://docs.knock.app/send-and-manage-data/preferences#preference-sets + https://docs.knock.app/reference#get-preferences-user#set-preferences-user + + - Parameters: + - preferenceId: The preferenceId for the PreferenceSet. + - preferenceSet: PreferenceSet with updated properties. + */ func setUserPreferences(preferenceId: String, preferenceSet: PreferenceSet) async throws -> Knock.PreferenceSet { try await self.preferenceModule.setUserPreferences(preferenceId: preferenceId, preferenceSet: preferenceSet) } diff --git a/Sources/Modules/Users/User.swift b/Sources/Modules/Users/User.swift index 9b5ef1b..f409f43 100644 --- a/Sources/Modules/Users/User.swift +++ b/Sources/Modules/Users/User.swift @@ -10,6 +10,8 @@ import Foundation public extension Knock { // MARK: Users + // https://docs.knock.app/reference#users#users + struct User: Codable { public let id: String public let name: String? diff --git a/Sources/Modules/Users/UserModule.swift b/Sources/Modules/Users/UserModule.swift index ff06b1a..0811417 100644 --- a/Sources/Modules/Users/UserModule.swift +++ b/Sources/Modules/Users/UserModule.swift @@ -36,6 +36,21 @@ internal class UserModule { public extension Knock { + /// Returns the userId that was set from the Knock.shared.signIn method. + func getUserId() async -> String? { + await environment.getUserId() + } + + func getUserId(completion: @escaping (String?) -> Void) { + Task { + completion(await environment.getUserId()) + } + } + + /** + Retrieve the current user, including all properties previously set. + https://docs.knock.app/reference#get-user#get-user + */ func getUser() async throws -> User { return try await userModule.getUser() } @@ -51,6 +66,9 @@ public extension Knock { } } + /** + Updates the current user and returns the updated User result. + */ func updateUser(user: User) async throws -> User { return try await userModule.updateUser(user: user) } diff --git a/Tests/KnockTests/AuthenticationTests.swift b/Tests/KnockTests/AuthenticationTests.swift index 4552e24..851f919 100644 --- a/Tests/KnockTests/AuthenticationTests.swift +++ b/Tests/KnockTests/AuthenticationTests.swift @@ -11,11 +11,13 @@ import XCTest final class AuthenticationTests: XCTestCase { override func setUpWithError() throws { - try? Knock.shared.setup(publishableKey: "pk_123", pushChannelId: "test") + Task { + try? await Knock.shared.setup(publishableKey: "pk_123", pushChannelId: "test") + } } override func tearDownWithError() throws { - Knock.shared = Knock() + Knock.shared.resetInstanceCompletely() } @@ -23,22 +25,27 @@ final class AuthenticationTests: XCTestCase { let userName = "testUserName" let userToken = "testUserToken" await Knock.shared.signIn(userId: userName, userToken: userToken) - - XCTAssertEqual(userName, Knock.shared.environment.userId) - XCTAssertEqual(userToken, Knock.shared.environment.userToken) + + let knockUserName = await Knock.shared.environment.getUserId() + let knockUserToken = await Knock.shared.environment.getUserToken() + XCTAssertEqual(userName, knockUserName) + XCTAssertEqual(userToken, knockUserToken) } func testSignOut() async throws { await Knock.shared.signIn(userId: "testUserName", userToken: "testUserToken") - Knock.shared.environment.userDevicePushToken = "test" - Knock.shared.environment.userDevicePushToken = "test" - Knock.shared.environment.userDevicePushToken = "test" + await Knock.shared.environment.setDeviceToken("test") + + await Knock.shared.authenticationModule.clearDataForSignOut() - Knock.shared.authenticationModule.clearDataForSignOut() + let userId = await Knock.shared.environment.getUserId() + let userToken = await Knock.shared.environment.getUserToken() + let publishableKey = await Knock.shared.environment.getPublishableKey() + let deviceToken = await Knock.shared.environment.getDeviceToken() - XCTAssertEqual(Knock.shared.environment.userId, nil) - XCTAssertEqual(Knock.shared.environment.userToken, nil) - XCTAssertEqual(Knock.shared.environment.publishableKey, "pk_123") - XCTAssertEqual(Knock.shared.environment.userDevicePushToken, "test") + XCTAssertEqual(userId, nil) + XCTAssertEqual(userToken, nil) + XCTAssertEqual(publishableKey, "pk_123") + XCTAssertEqual(deviceToken, "test") } } diff --git a/Tests/KnockTests/ChannelTests.swift b/Tests/KnockTests/ChannelTests.swift index 6dc527b..c1555d9 100644 --- a/Tests/KnockTests/ChannelTests.swift +++ b/Tests/KnockTests/ChannelTests.swift @@ -11,11 +11,56 @@ import XCTest final class ChannelTests: XCTestCase { override func setUpWithError() throws { - try? Knock.shared.setup(publishableKey: "pk_123", pushChannelId: "test") + Task { + try? await Knock.shared.setup(publishableKey: "pk_123", pushChannelId: "test") + } } override func tearDownWithError() throws { - Knock.shared = Knock() + Knock.shared.resetInstanceCompletely() + } + + func testPrepareTokensWithNoChannelData() { + let newToken = "newToken" + let previousTokens = [newToken] + let tokens = Knock.shared.channelModule.getTokenDataForServer(newToken: newToken, previousTokens: previousTokens, channelDataTokens: [], forDeregistration: false) + XCTAssertEqual(tokens, [newToken]) + } + + func testPrepareTokensWithDuplicateToken() async { + let newToken = "newToken" + let previousTokens = [newToken] + let channelTokens = [newToken] + + let tokens = Knock.shared.channelModule.getTokenDataForServer(newToken: newToken, previousTokens: previousTokens, channelDataTokens: channelTokens, forDeregistration: false) + XCTAssertEqual(tokens, [newToken]) + } + + func testPrepareTokensWithOldTokensNeedingToBeRemoved() { + let newToken = "newToken" + let previousTokens = ["1234", newToken] + let channelTokens = ["1234", "12345"] + + let tokens = Knock.shared.channelModule.getTokenDataForServer(newToken: newToken, previousTokens: previousTokens, channelDataTokens: channelTokens, forDeregistration: false) + XCTAssertEqual(tokens, ["12345", newToken]) + } + + func testPrepareTokensWithFirstTimeToken() async { + let newToken = "newToken" + let previousTokens = ["1234", newToken, "1"] + let channelTokens = ["1234", "12345"] + + let tokens = Knock.shared.channelModule.getTokenDataForServer(newToken: newToken, previousTokens: previousTokens, channelDataTokens: channelTokens, forDeregistration: false) + XCTAssertEqual(tokens, ["12345", newToken]) + } + + func testPrepareTokensForDeregistration() async { + let newToken = "newToken" + let previousTokens = ["1234", newToken, "1"] + let channelTokens = ["1234", "12345", newToken] + + let tokens = Knock.shared.channelModule.getTokenDataForServer(newToken: newToken, previousTokens: previousTokens, channelDataTokens: channelTokens, forDeregistration: true) + XCTAssertEqual(tokens, ["12345"]) } } diff --git a/Tests/KnockTests/KnockTests.swift b/Tests/KnockTests/KnockTests.swift index b6bc977..c3854d5 100644 --- a/Tests/KnockTests/KnockTests.swift +++ b/Tests/KnockTests/KnockTests.swift @@ -10,16 +10,18 @@ import XCTest final class KnockTests: XCTestCase { override func setUpWithError() throws { - try? Knock.shared.setup(publishableKey: "pk_123", pushChannelId: "test") + Task { + try? await Knock.shared.setup(publishableKey: "pk_123", pushChannelId: "test") + } } override func tearDownWithError() throws { - Knock.shared = Knock() + Knock.shared.resetInstanceCompletely() } - func testPublishableKeyError() throws { + func testPublishableKeyError() async throws { do { - let _ = try Knock.shared.setup(publishableKey: "sk_123", pushChannelId: nil) + let _ = try await Knock.shared.setup(publishableKey: "sk_123", pushChannelId: nil) XCTFail("Expected function to throw an error, but it did not.") } catch let error as Knock.KnockError { XCTAssertEqual(error, Knock.KnockError.wrongKeyError, "The error should be wrongKeyError") @@ -30,7 +32,7 @@ final class KnockTests: XCTestCase { func testMakingNetworkRequestBeforeKnockSetUp() async { try! tearDownWithError() - Knock.shared.environment.setUserInfo(userId: "test", userToken: nil) + await Knock.shared.environment.setUserInfo(userId: "test", userToken: nil) do { let _ = try await Knock.shared.getUser() XCTFail("Expected function to throw an error, but it did not.") diff --git a/Tests/KnockTests/UserTests.swift b/Tests/KnockTests/UserTests.swift index 8c55a5c..9002623 100644 --- a/Tests/KnockTests/UserTests.swift +++ b/Tests/KnockTests/UserTests.swift @@ -11,15 +11,15 @@ import XCTest final class UserTests: XCTestCase { override func setUpWithError() throws { - try? Knock.shared.setup(publishableKey: "pk_123", pushChannelId: "test") + Task { + try? await Knock.shared.setup(publishableKey: "pk_123", pushChannelId: "test") + } } override func tearDownWithError() throws { - // Put teardown code here. This method is called after the invocation of each test method in the class. + Knock.shared.resetInstanceCompletely() } - - func testUserDecoding() throws { let decoder = JSONDecoder()