From 8ee8889622fafb03d68f95a1d467bbf361b86f98 Mon Sep 17 00:00:00 2001 From: Jason Connery Date: Mon, 11 Nov 2024 12:49:34 +0000 Subject: [PATCH] Release candidate for URL Build Updates Contains changes to how URLs for API and Chat urls are computed to factor in hublet and environment more. Also contains some changes to demo app for editable config that were included in a test flight build but not shipped in an SDK update yet. --- CHANGELOG.md | 15 ++- Demo/HubspotDemo.xcodeproj/project.pbxproj | 8 +- Demo/HubspotDemo/AppViewModel.swift | 26 +++- Demo/HubspotDemo/Debug/EditConfigView.swift | 111 ++++++++++++++++++ Demo/HubspotDemo/Debug/SDKOptionsView.swift | 3 + Demo/HubspotDemo/HubspotDemoApp.swift | 3 +- Demo/HubspotDemo/Localizable.xcstrings | 41 +++++-- .../NavToolbarWithChatButtonView.swift | 1 + .../ToolbarWithChatButtonView.swift | 1 + Makefile | 28 ++++- Package.swift | 2 +- Sources/HubspotMobileSDK/API/HubspotAPI.swift | 20 ++-- Sources/HubspotMobileSDK/HubspotConfig.swift | 60 +++++++++- .../HubspotManager+Notifications.swift | 12 +- Sources/HubspotMobileSDK/HubspotManager.swift | 58 ++++++--- .../Views/Buttons/FloatingActionButton.swift | 10 +- .../Buttons/TextChatButtonChatButton.swift | 4 +- .../Views/ChatView/HubspotChatView.swift | 11 +- .../HubspotMobileSDKTests.swift | 32 ++++- 19 files changed, 370 insertions(+), 76 deletions(-) create mode 100644 Demo/HubspotDemo/Debug/EditConfigView.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d2f000..f329c37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,19 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +## [1.0.4] - 2024-XX-XX -## [1.0.2] - in progress +### In Progress + +- Added additional support for different hublets and environments when forming chat and api urls + +## [1.0.3] - 2024-05-22 + +### Fixed + +- Addressed bad merge causing compiler issues + +## [1.0.2] - 2024-05-22 ### Fixed @@ -35,4 +46,4 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Deprecated ### Removed ### Fixed -### Security \ No newline at end of file +### Security diff --git a/Demo/HubspotDemo.xcodeproj/project.pbxproj b/Demo/HubspotDemo.xcodeproj/project.pbxproj index 982d846..2e63ccb 100644 --- a/Demo/HubspotDemo.xcodeproj/project.pbxproj +++ b/Demo/HubspotDemo.xcodeproj/project.pbxproj @@ -22,6 +22,7 @@ 968FB4C32B173A8600D6FC68 /* Hubspot-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 968FB4C22B173A8600D6FC68 /* Hubspot-Info.plist */; }; 96AA34F32B45D0D8004FED6D /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 96AA34F22B45D0D8004FED6D /* Localizable.xcstrings */; }; 96B54C062B63B9CF00AF5545 /* DemoPlaceholderFeatureView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96B54C052B63B9CF00AF5545 /* DemoPlaceholderFeatureView.swift */; }; + 96BF7DF22C5A77CB00E50806 /* EditConfigView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96BF7DF12C5A77CB00E50806 /* EditConfigView.swift */; }; 96D9BA422B56CBB700E0A3E8 /* CustomPropertiesListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96D9BA412B56CBB700E0A3E8 /* CustomPropertiesListView.swift */; }; 96D9BA462B5EAF9600E0A3E8 /* NotificationsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96D9BA452B5EAF9600E0A3E8 /* NotificationsView.swift */; }; 96E75DAA2B0E70B2002DC1F5 /* FloatingButtonExampleContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96E75DA92B0E70B2002DC1F5 /* FloatingButtonExampleContainerView.swift */; }; @@ -48,6 +49,7 @@ 968FB4C22B173A8600D6FC68 /* Hubspot-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "Hubspot-Info.plist"; sourceTree = ""; }; 96AA34F22B45D0D8004FED6D /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; 96B54C052B63B9CF00AF5545 /* DemoPlaceholderFeatureView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DemoPlaceholderFeatureView.swift; sourceTree = ""; }; + 96BF7DF12C5A77CB00E50806 /* EditConfigView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditConfigView.swift; sourceTree = ""; }; 96D9BA412B56CBB700E0A3E8 /* CustomPropertiesListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomPropertiesListView.swift; sourceTree = ""; }; 96D9BA432B5E762700E0A3E8 /* HubspotDemo.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = HubspotDemo.entitlements; sourceTree = ""; }; 96D9BA452B5EAF9600E0A3E8 /* NotificationsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsView.swift; sourceTree = ""; }; @@ -167,6 +169,7 @@ isa = PBXGroup; children = ( 96E88BBE2B07857400F29EB5 /* SDKOptionsView.swift */, + 96BF7DF12C5A77CB00E50806 /* EditConfigView.swift */, ); path = Debug; sourceTree = ""; @@ -268,6 +271,7 @@ 968FB4BF2B10F02000D6FC68 /* ToolbarWithChatButtonView.swift in Sources */, 96E75DAA2B0E70B2002DC1F5 /* FloatingButtonExampleContainerView.swift in Sources */, 96E88BBF2B07857400F29EB5 /* SDKOptionsView.swift in Sources */, + 96BF7DF22C5A77CB00E50806 /* EditConfigView.swift in Sources */, 968FB4BD2B10BDFC00D6FC68 /* NavToolbarWithChatButtonView.swift in Sources */, 968FB4B72B0F7D9F00D6FC68 /* PlaceholderView.swift in Sources */, 9624CDCD2B068528009FFC2D /* HubspotDemoApp.swift in Sources */, @@ -433,7 +437,7 @@ SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_STRICT_CONCURRENCY = minimal; + SWIFT_STRICT_CONCURRENCY = complete; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 1; }; @@ -471,7 +475,7 @@ SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_STRICT_CONCURRENCY = minimal; + SWIFT_STRICT_CONCURRENCY = complete; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 1; }; diff --git a/Demo/HubspotDemo/AppViewModel.swift b/Demo/HubspotDemo/AppViewModel.swift index f048005..92ae4fe 100644 --- a/Demo/HubspotDemo/AppViewModel.swift +++ b/Demo/HubspotDemo/AppViewModel.swift @@ -31,7 +31,23 @@ class AppViewModel: ObservableObject { @MainActor func setupHubspot() { do { - try HubspotManager.configure() + if let newPortalId: String = UserDefaults.standard[.overridePortalId], + let newHublet: String = UserDefaults.standard[.overrideHublet], + let envStr: String = UserDefaults.standard[.overrideEnv], + let newEnv = HubspotEnvironment(rawValue: envStr) + { + let newChatFlow: String? = UserDefaults.standard[.overrideDefaultChatFlow] + + // User has previously edited the config via settings, so use those instead of the default which looks for the info plist in the bundle + // This way could also be used to support different test / production env at run time based on variables + HubspotManager.configure(portalId: newPortalId, + hublet: newHublet, + defaultChatFlow: newChatFlow, + environment: newEnv) + } else { + // the default configure which reads from file dropped into the project, for convenience. + try HubspotManager.configure() + } // If we already configured the demo with a token and email previously, re-set the user identity if let existingToken: String = UserDefaults.standard[.idToken], @@ -117,6 +133,10 @@ class AppViewModel: ObservableObject { enum StorageKeys: String { case idToken case userEmail + case overridePortalId + case overrideHublet + case overrideEnv + case overrideDefaultChatFlow } extension UserDefaults { @@ -128,6 +148,10 @@ extension UserDefaults { setValue(newValue, forKey: storageKey.rawValue) } } + + func removeObject(forStorageKey: StorageKeys) { + removeObject(forKey: forStorageKey.rawValue) + } } struct CustomProperty: Identifiable, Codable { diff --git a/Demo/HubspotDemo/Debug/EditConfigView.swift b/Demo/HubspotDemo/Debug/EditConfigView.swift new file mode 100644 index 0000000..2948b76 --- /dev/null +++ b/Demo/HubspotDemo/Debug/EditConfigView.swift @@ -0,0 +1,111 @@ +// EditConfigView.swift +// Hubspot Mobile SDK Demo Application +// +// Copyright © 2024 Hubspot, Inc. + +import HubspotMobileSDK +import SwiftUI + +struct EditConfigView: View { + @Environment(\.dismiss) var dismiss + + @State var enteredPortalId: String = "" + @State var enteredHublet: String = "" + @State var selectedEnvironment: HubspotEnvironment = .production + @State var enteredDefaultChatFlow: String = "" + + @State var showReset = false + + /// True when the state variables differ from the current values + var hasChanges: Bool { + let portalMatches = enteredPortalId.trimmingCharacters(in: .whitespacesAndNewlines) != HubspotManager.shared.portalId + let hubletMatches = enteredHublet.trimmingCharacters(in: .whitespacesAndNewlines) != HubspotManager.shared.hublet + let flowMatches = enteredDefaultChatFlow.trimmingCharacters(in: .whitespacesAndNewlines) != HubspotManager.shared.defaultChatFlow + let envMatches = selectedEnvironment == HubspotManager.shared.environment + + return !portalMatches || !hubletMatches || !flowMatches || !envMatches + } + + var body: some View { + VStack { + List { + Section { + TextField("Portal ID", text: $enteredPortalId) + TextField("Hublet", text: $enteredHublet) + TextField("Chat Flow", text: $enteredDefaultChatFlow) + Picker("Environment", selection: $selectedEnvironment, content: { + Text("Production") + .tag(HubspotEnvironment.production) + Text("QA") + .tag(HubspotEnvironment.qa) + }).pickerStyle(.segmented) + Button(action: saveChanges, label: { + Label("Save Config", systemImage: "slider.horizontal.3") + }).disabled(!hasChanges) + } + Section { + Text("Clear any edited values previously saved, and go back to using the bundled config values") + Button(role: .destructive, action: { showReset = true }, label: { + Label("Reset to default", systemImage: "minus.square") + }) + .confirmationDialog("Reset Config", isPresented: $showReset) { + Button("Reset", role: .destructive, action: resetConfig) + } + } + + Section { + Text("Note: Editing the config during runtime may result in some inconsistent behaviour after editing. Fully stopping the app via multi tasker and relaunching may be a convenient way to reset any inconsistent behaviour due to run time config changes.") + .font(.callout) + } + }.onAppear(perform: setInitialValues) + } + .navigationTitle("Edit Config") + } + + func setInitialValues() { + enteredPortalId = HubspotManager.shared.portalId ?? "" + enteredHublet = HubspotManager.shared.hublet ?? "" + selectedEnvironment = HubspotManager.shared.environment + enteredDefaultChatFlow = HubspotManager.shared.defaultChatFlow ?? "" + } + + func saveChanges() { + guard hasChanges else { + return + } + + let enteredPortalId = enteredPortalId.trimmingCharacters(in: .whitespacesAndNewlines) + let enteredHublet = enteredHublet.trimmingCharacters(in: .whitespacesAndNewlines) + let enteredDefaultChatFlow = enteredDefaultChatFlow.trimmingCharacters(in: .whitespacesAndNewlines) + + UserDefaults.standard[.overridePortalId] = enteredPortalId + UserDefaults.standard[.overrideHublet] = enteredHublet + UserDefaults.standard[.overrideEnv] = selectedEnvironment.rawValue + UserDefaults.standard[.overrideDefaultChatFlow] = enteredDefaultChatFlow + + logger.trace("Updating configuration on shared manager") + HubspotManager.configure(portalId: enteredPortalId, + hublet: enteredHublet, + defaultChatFlow: enteredDefaultChatFlow, + environment: selectedEnvironment) + + dismiss() + } + + func resetConfig() { + UserDefaults.standard.removeObject(forStorageKey: .overridePortalId) + UserDefaults.standard.removeObject(forStorageKey: .overrideHublet) + UserDefaults.standard.removeObject(forStorageKey: .overrideEnv) + UserDefaults.standard.removeObject(forStorageKey: .overrideDefaultChatFlow) + + logger.trace("Triggering initial configure call") + try? HubspotManager.configure() + dismiss() + } +} + +#Preview { + NavigationStack { + EditConfigView() + } +} diff --git a/Demo/HubspotDemo/Debug/SDKOptionsView.swift b/Demo/HubspotDemo/Debug/SDKOptionsView.swift index ad36d70..e9bed71 100644 --- a/Demo/HubspotDemo/Debug/SDKOptionsView.swift +++ b/Demo/HubspotDemo/Debug/SDKOptionsView.swift @@ -66,6 +66,9 @@ struct SDKOptionsView: View { detailRow(label: "Hublet", value: manager.hublet ?? "") detailRow(label: "Environment", value: manager.environment.description) detailRow(label: "Default Chat Flow", value: manager.defaultChatFlow ?? "") + NavigationLink(destination: EditConfigView(), label: { + Text("Edit Config") + }) } } diff --git a/Demo/HubspotDemo/HubspotDemoApp.swift b/Demo/HubspotDemo/HubspotDemoApp.swift index e118ead..62714e7 100644 --- a/Demo/HubspotDemo/HubspotDemoApp.swift +++ b/Demo/HubspotDemo/HubspotDemoApp.swift @@ -35,7 +35,8 @@ struct HubspotDemoApp: App { } /// Example of a app level notification delegate that also forwards calls to hubspot delegate -class DemoAppNotificationDelegate: NSObject, ObservableObject, UNUserNotificationCenterDelegate { +@MainActor +class DemoAppNotificationDelegate: NSObject, ObservableObject, @preconcurrency UNUserNotificationCenterDelegate { /// Count of recent pushes received in the foreground @Published var countOfpushesReceived = 0 /// Count of the notifications received we think are for hubspot content diff --git a/Demo/HubspotDemo/Localizable.xcstrings b/Demo/HubspotDemo/Localizable.xcstrings index e985034..f2dddb8 100644 --- a/Demo/HubspotDemo/Localizable.xcstrings +++ b/Demo/HubspotDemo/Localizable.xcstrings @@ -67,9 +67,15 @@ }, "Chat" : { + }, + "Chat Flow" : { + }, "Chat with us" : { + }, + "Clear any edited values previously saved, and go back to using the bundled config values" : { + }, "Clear data" : { @@ -118,15 +124,6 @@ }, "Default flow - value taken from initial config: %@" : { - }, - "Demo 1" : { - - }, - "Demo 2" : { - - }, - "Demo 3" : { - }, "Details" : { @@ -136,6 +133,9 @@ }, "Dismiss" : { + }, + "Edit Config" : { + }, "Email" : { @@ -270,6 +270,9 @@ }, "Not set" : { + }, + "Note: Editing the config during runtime may result in some inconsistent behaviour after editing. Fully stopping the app via multi tasker and relaunching may be a convenient way to reset any inconsistent behaviour due to run time config changes." : { + }, "Notifications" : { @@ -312,7 +315,10 @@ "Portal ID" : { }, - "Preview" : { + "Production" : { + + }, + "QA" : { }, "Recent Pushes" : { @@ -323,6 +329,18 @@ }, "Request Token" : { + }, + "Reset" : { + + }, + "Reset Config" : { + + }, + "Reset to default" : { + + }, + "Save Config" : { + }, "SDK Options" : { @@ -392,9 +410,6 @@ }, "This is tab 3" : { - }, - "This is the preview content" : { - }, "Token" : { diff --git a/Demo/HubspotDemo/Tab And Toolbar Examples/NavToolbarWithChatButtonView.swift b/Demo/HubspotDemo/Tab And Toolbar Examples/NavToolbarWithChatButtonView.swift index 40cb567..5865f4f 100644 --- a/Demo/HubspotDemo/Tab And Toolbar Examples/NavToolbarWithChatButtonView.swift +++ b/Demo/HubspotDemo/Tab And Toolbar Examples/NavToolbarWithChatButtonView.swift @@ -5,6 +5,7 @@ import HubspotMobileSDK import SwiftUI + struct NavToolbarWithChatButtonView: View { @State var showingChat = false diff --git a/Demo/HubspotDemo/Tab And Toolbar Examples/ToolbarWithChatButtonView.swift b/Demo/HubspotDemo/Tab And Toolbar Examples/ToolbarWithChatButtonView.swift index f01dab7..36718c8 100644 --- a/Demo/HubspotDemo/Tab And Toolbar Examples/ToolbarWithChatButtonView.swift +++ b/Demo/HubspotDemo/Tab And Toolbar Examples/ToolbarWithChatButtonView.swift @@ -5,6 +5,7 @@ import HubspotMobileSDK import SwiftUI + struct ToolbarWithChatButtonView: View { @State var showingChat = false diff --git a/Makefile b/Makefile index 3c14cfb..30ec722 100644 --- a/Makefile +++ b/Makefile @@ -26,15 +26,31 @@ clean: rm -rf ${LEGACY_DEMO_ARCHIVE_DIR} rm -rf ${FRAMEWORKS_OUTPUT} +swift-lint: + + swift format lint --recursive Sources + swift format lint --recursive Tests + swift format lint --recursive Demo + swift format lint --recursive UIKitDemo + lint: - swiftformat --lint --swiftversion 5.9 Sources - swiftformat --lint --swiftversion 5.9 Demo - swiftformat --lint --swiftversion 5.9 UIKitDemo + swiftformat --lint --swiftversion 5.10 Sources + swiftformat --lint --swiftversion 5.10 Tests + swiftformat --lint --swiftversion 5.10 Demo + swiftformat --lint --swiftversion 5.10 UIKitDemo format: - swiftformat --swiftversion 5.9 Sources - swiftformat --swiftversion 5.9 Demo - swiftformat --swiftversion 5.9 UIKitDemo + swiftformat --swiftversion 5.10 Sources + swiftformat --swiftversion 5.10 Tests + swiftformat --swiftversion 5.10 Demo + swiftformat --swiftversion 5.10 UIKitDemo + +swift-format: + + swift format --in-place --recursive Sources + swift format --in-place --recursive Tests + swift format --in-place --recursive Demo + swift format --in-place --recursive UIKitDemo make-doc-archive: xcodebuild \ diff --git a/Package.swift b/Package.swift index 4044296..8cd977e 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version: 5.9 +// swift-tools-version: 5.10 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription diff --git a/Sources/HubspotMobileSDK/API/HubspotAPI.swift b/Sources/HubspotMobileSDK/API/HubspotAPI.swift index 62b8eab..79b85c2 100644 --- a/Sources/HubspotMobileSDK/API/HubspotAPI.swift +++ b/Sources/HubspotMobileSDK/API/HubspotAPI.swift @@ -27,12 +27,10 @@ class HubspotAPI { private let jsonDecoder: JSONDecoder = .init() private let urlSession = URLSession(configuration: .default) - private let baseUrl = URL(string: "https://api.hubapi.com/")! - func sendDeviceToken(token: Data, portalId: String) async throws { + func sendDeviceToken(hublet: Hublet, token: Data, portalId: String) async throws { // POST - - let apiUrl = baseUrl.appendingPathComponent("livechat-public/v1/mobile-sdk/device-token") + let apiUrl = hublet.apiURL.appendingPathComponent("livechat-public/v1/mobile-sdk/device-token") guard var components = URLComponents(url: apiUrl, resolvingAgainstBaseURL: false) else { throw APIError.requestError @@ -62,9 +60,9 @@ class HubspotAPI { #endif } - func deleteDeviceToken(token: Data, portalId: String) async throws { + func deleteDeviceToken(hublet: Hublet, token: Data, portalId: String) async throws { // DELETE - let apiUrl = baseUrl.appendingPathComponent("livechat-public/v1/mobile-sdk/device-token/\(token.toHexString())") + let apiUrl = hublet.apiURL.appendingPathComponent("livechat-public/v1/mobile-sdk/device-token/\(token.toHexString())") guard var components = URLComponents(url: apiUrl, resolvingAgainstBaseURL: false) else { throw APIError.requestError @@ -90,14 +88,15 @@ class HubspotAPI { /// Post chat properties for a specific thread id to the api. /// - Parameters: + /// - hublet: destination hublet /// - properties: The collection of properties to post. /// - visitorIdToken: The token set by the app to identify user. Optional. /// - threadId: The thread id read from the chat view / javascript bridge that identifies the current open thread /// - portalId: Account portal id - func sendChatProperties(properties: [String: String], visitorIdToken: String?, email: String?, threadId: String, portalId: String) async throws { + func sendChatProperties(hublet: Hublet, properties: [String: String], visitorIdToken: String?, email: String?, threadId: String, portalId: String) async throws { let urlProperties = ["portalId": portalId, "threadId": threadId] - let apiUrl = baseUrl.appendingPathComponent("livechat-public/v1/mobile-sdk/metadata") + let apiUrl = hublet.apiURL.appendingPathComponent("livechat-public/v1/mobile-sdk/metadata") guard var urlBuilder = URLComponents(url: apiUrl, resolvingAgainstBaseURL: false) else { throw APIError.requestError } @@ -134,14 +133,15 @@ class HubspotAPI { /// - WARNING: Embedding access token for your product in app is not recommended - This was originally for demo purposes, and may be removed. Strongly consider creating a token as part of app server infrastructure instead. /// /// - Parameters: + /// - hublet: destination hublet /// - accessToken: The access token for your application, as returned by the Hubspot dashboard /// - email: the email of the user /// - firstName: users first name /// - lastName: users last name /// - Returns: The generated JWT token - func createVisitorToken(accessToken: String, email: String, firstName: String, lastName: String) async throws -> String { + func createVisitorToken(hublet: Hublet, accessToken: String, email: String, firstName: String, lastName: String) async throws -> String { // Later, if we have lots of requests we can refactor this to have common base path - let url = baseUrl.appendingPathComponent("conversations/v3/visitor-identification/tokens/create") + let url = hublet.apiURL.appendingPathComponent("conversations/v3/visitor-identification/tokens/create") let requestModel = CreateVisitorTokenRequest(email: email, firstName: firstName, lastName: lastName) let requestData = try jsonEncoder.encode(requestModel) diff --git a/Sources/HubspotMobileSDK/HubspotConfig.swift b/Sources/HubspotMobileSDK/HubspotConfig.swift index f4661ef..5e010ff 100644 --- a/Sources/HubspotMobileSDK/HubspotConfig.swift +++ b/Sources/HubspotMobileSDK/HubspotConfig.swift @@ -6,7 +6,7 @@ import Foundation /// Enum used during configuration. The default is production - if in doubt choose production -public enum HubspotEnvironment: String, Codable, CustomStringConvertible { +public enum HubspotEnvironment: String, Codable, CustomStringConvertible, Sendable { /// QA environment , mostly for internal use case qa /// Production environment, the most commonly used environment @@ -28,16 +28,70 @@ struct Hublet { let defaultUS = "na1" let id: String + let environment: HubspotEnvironment /// The format of subdomain varies between hublets - var appsSubDomain: String { - if id.lowercased() == defaultUS { + private var appsSubDomain: String { + let id = id.lowercased() + if id == defaultUS { return "app" } else { // other hublets like eu1 have hublet in the subdomain return "app-\(id)" } } + + private var appsDomain: String { + // Right now, qa env has its own domain + switch environment { + case .production: + return "hubspot.com" + case .qa: + return "hubspotqa.com" + } + } + + /// The format of subdomain varies between hublets + private var apiSubDomain: String { + let id = id.lowercased() + if id == defaultUS { + return "api" + } else { + // other hublets like eu1 have hublet in the subdomain + return "api-\(id)" + } + } + + private var apiDomain: String { + // Right now, qa env has its own domain + switch environment { + case .production: + return "hubapi.com" + case .qa: + return "hubapiqa.com" + } + } + + /// hostname used for the embedded chat page + var hostname: String { + return appsSubDomain + "." + appsDomain + } + + /// hostname used for api calls + var apiHostname: String { + return apiSubDomain + "." + apiDomain + } + + /// base url for api calls - append path before using + var apiURL: URL { + var components = URLComponents() + components.scheme = "https" + components.host = apiHostname + guard let url = components.url else { + fatalError("Unable to build URL from configuration") + } + return url + } } /// Errors relating to setting up SDK diff --git a/Sources/HubspotMobileSDK/HubspotManager+Notifications.swift b/Sources/HubspotMobileSDK/HubspotManager+Notifications.swift index 756ebc2..cff2643 100644 --- a/Sources/HubspotMobileSDK/HubspotManager+Notifications.swift +++ b/Sources/HubspotMobileSDK/HubspotManager+Notifications.swift @@ -122,13 +122,13 @@ public extension HubspotManager { /// Making the `HubspotManager` your user notification delete is not required, but its an option for convenience in situations where another delegate doesn't already exist. extension HubspotManager: UNUserNotificationCenterDelegate { /// Use this method to help identify incoming notifications that are hubspot related , incase you wish to handle them differently - public func isHubspotNotification(notification: UNNotification) -> Bool { + public nonisolated func isHubspotNotification(notification: UNNotification) -> Bool { let notificationData = notification.request.content.userInfo return isHubspotNotification(notificationData: notificationData) } /// Use this method to help identify incoming notifications that are hubspot related , incase you wish to handle them differently - public func isHubspotNotification(notificationData: [AnyHashable: Any]) -> Bool { + public nonisolated func isHubspotNotification(notificationData: [AnyHashable: Any]) -> Bool { let hasAHubspotKey = notificationData.contains(where: { key, _ in guard let key = key as? String else { return false @@ -148,7 +148,7 @@ extension HubspotManager: UNUserNotificationCenterDelegate { /// A ``HubspotManager`` instanance, like ``HubspotManager/shared`` can be used as a notification centre delegate, in situations where all notifications are from hubspot. If you have your own notification delegate, instead call this method from within your own delegate for notifications that are hubspot related. /// /// - public func userNotificationCenter(_: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { + public nonisolated func userNotificationCenter(_: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { if isHubspotNotification(notification: response.notification) { guard let chatData = PushNotificationChatData(notification: response.notification) else { // none of the expected data in the message @@ -161,14 +161,16 @@ extension HubspotManager: UNUserNotificationCenterDelegate { self.newMessage.send(chatData) } } else { - logger.info("Push message handled by HubspotManager that isn't detected as as Hubspot notifiation. This may be a misconfiguration.") + Task { @MainActor in + logger.info("Push message handled by HubspotManager that isn't detected as as Hubspot notifiation. This may be a misconfiguration. \(response)") + } } completionHandler() } /// A ``HubspotManager`` instanance, like ``HubspotManager/shared`` can be used as a notification centre delegate, in situations where all notifications are from hubspot. If you have your own notification delegate, instead call this method from within your own delegate for notifications that are hubspot related. /// - public func userNotificationCenter(_: UNUserNotificationCenter, willPresent _: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { + public nonisolated func userNotificationCenter(_: UNUserNotificationCenter, willPresent _: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { completionHandler([.banner, .sound]) } } diff --git a/Sources/HubspotMobileSDK/HubspotManager.swift b/Sources/HubspotMobileSDK/HubspotManager.swift index 8fc4f15..e595cfa 100644 --- a/Sources/HubspotMobileSDK/HubspotManager.swift +++ b/Sources/HubspotMobileSDK/HubspotManager.swift @@ -25,6 +25,7 @@ private func createDefaultHubspotLogger() -> Logger { /// For more setup instructions, see /// /// +@MainActor public class HubspotManager: NSObject, ObservableObject { /// Shared instance that can be used app wide, instead of creating an managing own instance. /// If not using this instance, and instead managing your own instance, make sure to pass your instance as an argument to the ``HubspotChatView`` or other components. @@ -75,8 +76,14 @@ public class HubspotManager: NSObject, ObservableObject { } } - /// The manager uses combine to listen for some device notifications, like changes - var combineSubs: Set = [] + private var hubletModel: Hublet? { + guard let hublet + else { + return nil + } + + return Hublet(id: hublet, environment: environment) + } /// Record if we turned on battery monitoring. If we didn't turn it on , we might not want to turn it off again. var didWeEnableBatterMonitoring: Bool = false @@ -130,6 +137,7 @@ public class HubspotManager: NSObject, ObservableObject { portalId = config.portalId environment = config.environment defaultChatFlow = config.defaultChatFlow + objectWillChange.send() sendPushTokenIfNeeded() @@ -154,6 +162,8 @@ public class HubspotManager: NSObject, ObservableObject { self.hublet = hublet self.environment = environment self.defaultChatFlow = defaultChatFlow + + objectWillChange.send() } /// Convenience to set the logger to the disabled logger @@ -193,6 +203,7 @@ public class HubspotManager: NSObject, ObservableObject { switch self.pushTokenSyncState { case .notSent: shouldSendToken = true + case let .sending(lastActionDate): let interval = abs(lastActionDate.timeIntervalSinceNow) @@ -220,7 +231,11 @@ public class HubspotManager: NSObject, ObservableObject { self.pushTokenSyncState = .sending(.now) do { - try await api.sendDeviceToken(token: pushToken, portalId: portalId) + guard let hubletModel else { + throw HubspotConfigError.missingConfiguration + } + try await api.sendDeviceToken(hublet: hubletModel, token: pushToken, portalId: portalId) + if !Task.isCancelled { self.pushTokenSyncState = .sent(.now) } else { @@ -313,7 +328,7 @@ public class HubspotManager: NSObject, ObservableObject { properties[ChatPropertyKey.notificationPermissions.rawValue] = "false" } - properties[ChatPropertyKey.operatingSystemVersion.rawValue] = await "\(UIDevice.current.systemName) \(UIDevice.current.systemVersion)" + properties[ChatPropertyKey.operatingSystemVersion.rawValue] = "\(UIDevice.current.systemName) \(UIDevice.current.systemVersion)" if let infoDict = Bundle.main.infoDictionary, let shortVersion = infoDict["CFBundleShortVersionString"], @@ -322,21 +337,21 @@ public class HubspotManager: NSObject, ObservableObject { properties[ChatPropertyKey.appVersion.rawValue] = "\(shortVersion).\(buildVersion)" } - let screenBounds = await UIScreen.main.bounds + let screenBounds = UIScreen.main.bounds let screenWidth = screenBounds.width let screenHeight = screenBounds.height - let scale = await UIScreen.main.scale + let scale = UIScreen.main.scale properties[ChatPropertyKey.screenSize.rawValue] = "\(Int(screenWidth))x\(Int(screenHeight))" properties[ChatPropertyKey.screenResolution.rawValue] = "\(Int(screenWidth * scale))x\(Int(screenHeight * scale))" - properties[ChatPropertyKey.deviceOrientation.rawValue] = await UIDevice.current.orientation.hubspotApiValue + properties[ChatPropertyKey.deviceOrientation.rawValue] = UIDevice.current.orientation.hubspotApiValue // ignore when battery is reported as -1, or some negative to indicate its invalid - if await UIDevice.current.batteryLevel >= 0 { - let batteryLevelRounded = await Int((UIDevice.current.batteryLevel * 100).rounded()) + if UIDevice.current.batteryLevel >= 0 { + let batteryLevelRounded = Int((UIDevice.current.batteryLevel * 100).rounded()) properties[ChatPropertyKey.batteryLevel.rawValue] = String(batteryLevelRounded) } - properties[ChatPropertyKey.batteryState.rawValue] = await UIDevice.current.batteryState.hubspotApiValue + properties[ChatPropertyKey.batteryState.rawValue] = UIDevice.current.batteryState.hubspotApiValue // Platform is just fixed, no point in trying to detect it at runtime properties[ChatPropertyKey.platform.rawValue] = "ios" @@ -350,10 +365,10 @@ public class HubspotManager: NSObject, ObservableObject { sendPushTokenTask?.cancel() /// First, remove the push token, if we can - if let pushToken, let portalId { + if let pushToken, let portalId, let hubletModel { Task { do { - try await api.deleteDeviceToken(token: pushToken, portalId: portalId) + try await api.deleteDeviceToken(hublet: hubletModel, token: pushToken, portalId: portalId) } catch { logger.error("Error deleting push token from api: \(error)") } @@ -379,20 +394,22 @@ public class HubspotManager: NSObject, ObservableObject { /// - Returns: URL to embed to show mobile chat /// - Throws: ``HubspotConfigError.missingConfiguration`` if app settings like portal id or hublet are missing, or ``HubspotConfigError.missingChatFlow`` if no chat flow is provided and no default value exists func chatUrl(withPushData: PushNotificationChatData?, forChatFlow: String? = nil) throws -> URL { - guard let hublet = hublet.flatMap(Hublet.init(id:)), + guard let hublet, let portalId else { throw HubspotConfigError.missingConfiguration } + let hubletModel = Hublet(id: hublet, environment: environment) + var components = URLComponents() components.scheme = "https" - components.host = hublet.appsSubDomain + ".hubspot.com" + components.host = hubletModel.hostname components.path = "/conversations-visitor-embed" var queryItems: [String: String] = [ "portalId": portalId, - "hublet": hublet.id, + "hublet": hubletModel.id, "env": environment.rawValue, ] @@ -443,7 +460,7 @@ public class HubspotManager: NSObject, ObservableObject { /// Handle obtaining a thread id - once the thread id is known , we can post chat properties to the API. This method is used by chat views once they've extracted ID from UI / Javascript Bridge. /// - Parameter threadId: the thread id retrieved from the active chat view func handleThreadOpened(threadId: String) { - guard let portalId else { + guard let portalId, let hubletModel else { return } @@ -453,7 +470,8 @@ public class HubspotManager: NSObject, ObservableObject { let props = await finalizeChatProperties() do { - try await api.sendChatProperties(properties: props, + try await api.sendChatProperties(hublet: hubletModel, + properties: props, visitorIdToken: self.userIdentityToken, email: self.userEmailAddress, threadId: threadId, @@ -496,6 +514,10 @@ public extension HubspotManager { /// - Returns: The generated JWT token @available(*, deprecated, message: "This is for development only and may be removed - acquiring an access token should be done as part of your products server infrastructure") func aquireUserIdentityToken(accessToken: String, email: String, firstName: String, lastName: String) async throws -> String { - return try await api.createVisitorToken(accessToken: accessToken, email: email, firstName: firstName, lastName: lastName) + guard let hubletModel else { + throw HubspotConfigError.missingConfiguration + } + + return try await api.createVisitorToken(hublet: hubletModel, accessToken: accessToken, email: email, firstName: firstName, lastName: lastName) } } diff --git a/Sources/HubspotMobileSDK/Views/Buttons/FloatingActionButton.swift b/Sources/HubspotMobileSDK/Views/Buttons/FloatingActionButton.swift index 6e0c7e5..9e1a2b1 100644 --- a/Sources/HubspotMobileSDK/Views/Buttons/FloatingActionButton.swift +++ b/Sources/HubspotMobileSDK/Views/Buttons/FloatingActionButton.swift @@ -41,10 +41,10 @@ public struct FloatingActionButton: View { /// - Parameters: /// - manager: The manager to use for getting a chat session. By defautl the shared manager is used. /// - chatFlow: The specific chat flow to open. Optional. - public init(manager: HubspotManager = .shared, + public init(manager: HubspotManager? = nil, chatFlow: String? = nil) { - self.manager = manager + self.manager = manager ?? HubspotManager.shared self.chatFlow = chatFlow } @@ -79,8 +79,8 @@ struct FloatingActionButtonOverlayModifier: ViewModifier { let manager: HubspotManager let chatFlow: String? - init(manager: HubspotManager = .shared, chatFlow: String? = nil) { - self.manager = manager + init(manager: HubspotManager? = nil, chatFlow: String? = nil) { + self.manager = manager ?? HubspotManager.shared self.chatFlow = chatFlow } @@ -97,7 +97,7 @@ public extension View { /// - Parameters: /// - manager: The hubspot manager to use /// - chatFlow: the chat flow targeting parameter to use - func overlayHubspotFloatingActionButton(manager: HubspotManager = .shared, chatFlow: String? = nil) -> some View { + func overlayHubspotFloatingActionButton(manager: HubspotManager? = nil, chatFlow: String? = nil) -> some View { return modifier(FloatingActionButtonOverlayModifier(manager: manager, chatFlow: chatFlow)) } } diff --git a/Sources/HubspotMobileSDK/Views/Buttons/TextChatButtonChatButton.swift b/Sources/HubspotMobileSDK/Views/Buttons/TextChatButtonChatButton.swift index 437b972..044e374 100644 --- a/Sources/HubspotMobileSDK/Views/Buttons/TextChatButtonChatButton.swift +++ b/Sources/HubspotMobileSDK/Views/Buttons/TextChatButtonChatButton.swift @@ -24,9 +24,9 @@ public struct TextChatButton: View { /// - text: The text in the button - if nil, default text is used. /// - manager: The manager to use for getting a chat session. By defautl the shared manager is used. /// - chatFlow: The specific chat flow to open. Optional. - public init(text: LocalizedStringKey? = nil, manager: HubspotManager = .shared, chatFlow: String? = nil) { + public init(text: LocalizedStringKey? = nil, manager: HubspotManager? = nil, chatFlow: String? = nil) { customText = text - self.manager = manager + self.manager = manager ?? .shared self.chatFlow = chatFlow } diff --git a/Sources/HubspotMobileSDK/Views/ChatView/HubspotChatView.swift b/Sources/HubspotMobileSDK/Views/ChatView/HubspotChatView.swift index d4f9adb..f4cd113 100644 --- a/Sources/HubspotMobileSDK/Views/ChatView/HubspotChatView.swift +++ b/Sources/HubspotMobileSDK/Views/ChatView/HubspotChatView.swift @@ -69,11 +69,11 @@ public struct HubspotChatView: View { /// - manager: manager to use when creating urls for account and getting user properties /// - pushData: Struct containing any of the hubspot values from the push body payload. /// - chatFlow: The specific chat flow to open, if any - public init(manager: HubspotManager = HubspotManager.shared, + public init(manager: HubspotManager? = nil, pushData: PushNotificationChatData? = nil, chatFlow: String? = nil) { - self.manager = manager + self.manager = manager ?? .shared self.chatFlow = chatFlow self.pushData = pushData } @@ -374,10 +374,8 @@ struct HubspotChatWebView: UIViewRepresentable { } // We are looking to get conversation object , if sent. - if - - let conversationDict = dict["conversation"] as? [String: Any], - let conversationId = conversationDict["conversationId"] as? Int + if let conversationDict = dict["conversation"] as? [String: Any], + let conversationId = conversationDict["conversationId"] as? Int { // Now we know the id of newly selected thread, we can inform the manager which will handle next steps for data manager.handleThreadOpened(threadId: String(conversationId)) @@ -403,6 +401,7 @@ struct HubspotChatWebView: UIViewRepresentable { case .formSubmitted, .backForward, .reload, .formResubmitted, .other: return .allow + @unknown default: return .allow } diff --git a/Tests/HubspotMobileSDKTests/HubspotMobileSDKTests.swift b/Tests/HubspotMobileSDKTests/HubspotMobileSDKTests.swift index 8129d28..d4b5428 100644 --- a/Tests/HubspotMobileSDKTests/HubspotMobileSDKTests.swift +++ b/Tests/HubspotMobileSDKTests/HubspotMobileSDKTests.swift @@ -1,6 +1,12 @@ -import XCTest +// HubspotMobileSDKTests.swift +// Hubspot Mobile SDK +// +// Copyright © 2024 Hubspot, Inc. + @testable import HubspotMobileSDK +import XCTest +@MainActor final class HubspotMobileSDKTests: XCTestCase { func testDeviceModelReading() throws { let manager = HubspotManager() @@ -8,3 +14,27 @@ final class HubspotMobileSDKTests: XCTestCase { XCTAssertFalse(value.isEmpty) } } + +@MainActor +final class HubspotHubletTests: XCTestCase { + func testNA1Hublet() throws { + let hublet = Hublet(id: "na1", environment: .production) + + XCTAssertEqual(hublet.hostname, "app.hubspot.com") + XCTAssertEqual(hublet.apiURL.absoluteString, "https://api.hubapi.com") + + let hubletQA = Hublet(id: "NA1", environment: .qa) + XCTAssertEqual(hubletQA.hostname, "app.hubspotqa.com") + XCTAssertEqual(hubletQA.apiURL.absoluteString, "https://api.hubapiqa.com") + } + + func testEU1Hublet() throws { + let hublet = Hublet(id: "eu1", environment: .production) + XCTAssertEqual(hublet.hostname, "app-eu1.hubspot.com") + XCTAssertEqual(hublet.apiURL.absoluteString, "https://api-eu1.hubapi.com") + + let hubletQA = Hublet(id: "EU1", environment: .qa) + XCTAssertEqual(hubletQA.hostname, "app-eu1.hubspotqa.com") + XCTAssertEqual(hubletQA.apiURL.absoluteString, "https://api-eu1.hubapiqa.com") + } +}