diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b0b9490..2c00cd4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 6.10.0 + +- Add Adact campaign support to `GamifyWidgetSDK`: `initialize(widgetUrl:adactUrl:)`, `openAdactCampaign(from:params:)`, `closeAdactCampaign()`, `closeWidget()`, and `buildAdactCampaignUrl(params:)`. Opens `{adactUrl}/embedded/{campaignId}` with optional `cid` and `customerIdToken` query params (same contract as the Web / Android SDKs). Adact does not use the loyalty READY→INIT handshake. + ## 6.9.0 - Add `OptimoveConfigBuilder.enableOverlayMessaging(sessionLengthMinutes:)` overload to configure the overlay messaging session window in minutes (minimum 15). The existing `enableOverlayMessaging(sessionLengthHours:)` overload is unchanged. diff --git a/OptimoveCore.podspec b/OptimoveCore.podspec index b8b9b9a6..f4bec664 100644 --- a/OptimoveCore.podspec +++ b/OptimoveCore.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'OptimoveCore' - s.version = '6.9.0' + s.version = '6.10.0' s.summary = 'Official Optimove SDK for iOS. Core framework.' s.description = 'The core framework is used to share code-base between other Optimove frameworks.' s.homepage = 'https://github.com/optimove-tech/Optimove-SDK-iOS' diff --git a/OptimoveCore/Sources/Classes/Constants/SDKVersion.swift b/OptimoveCore/Sources/Classes/Constants/SDKVersion.swift index 3ab6f1ad..7daa484e 100644 --- a/OptimoveCore/Sources/Classes/Constants/SDKVersion.swift +++ b/OptimoveCore/Sources/Classes/Constants/SDKVersion.swift @@ -1,3 +1,3 @@ // Copyright © 2019 Optimove. All rights reserved. -public let SDKVersion = "6.9.0" +public let SDKVersion = "6.10.0" diff --git a/OptimoveNotificationServiceExtension.podspec b/OptimoveNotificationServiceExtension.podspec index b346b5a1..200621a2 100644 --- a/OptimoveNotificationServiceExtension.podspec +++ b/OptimoveNotificationServiceExtension.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'OptimoveNotificationServiceExtension' - s.version = '6.9.0' + s.version = '6.10.0' s.summary = 'Official Optimove SDK for iOS. Notification service extension framework.' s.description = 'The notification service extension is used for handling additional content in push notifications.' s.homepage = 'https://github.com/optimove-tech/Optimove-SDK-iOS' diff --git a/OptimoveSDK.podspec b/OptimoveSDK.podspec index 5d452d2f..4b12bb4b 100644 --- a/OptimoveSDK.podspec +++ b/OptimoveSDK.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'OptimoveSDK' - s.version = '6.9.0' + s.version = '6.10.0' s.summary = 'Official Optimove SDK for iOS.' s.description = 'The Optimove SDK framework is used for reporting events and receive push notifications.' s.homepage = 'https://github.com/optimove-tech/Optimove-SDK-iOS' diff --git a/OptimoveSDK/Sources/Classes/GamifyWidget/GamifyWidgetSDK.swift b/OptimoveSDK/Sources/Classes/GamifyWidget/GamifyWidgetSDK.swift index 6bfa9459..d86e0586 100644 --- a/OptimoveSDK/Sources/Classes/GamifyWidget/GamifyWidgetSDK.swift +++ b/OptimoveSDK/Sources/Classes/GamifyWidget/GamifyWidgetSDK.swift @@ -2,28 +2,48 @@ import UIKit -/// Entry point for the Gamify Widget SDK. +/// Entry point for the Gamify Widget SDK (loyalty + Adact). /// -/// Usage: +/// Usage (loyalty): /// GamifyWidgetSDK.initialize(widgetUrl: "https://your-widget.example.com") /// GamifyWidgetSDK.open(from: viewController, userId: "u123") +/// +/// Usage (Adact — `adactUrl` from onboarding / retrieval service): +/// GamifyWidgetSDK.initialize(widgetUrl: widgetUrl, adactUrl: "https://adact-campaign.example.com/") +/// GamifyWidgetSDK.openAdactCampaign(from: viewController, params: OpenAdactParams(campaignId: 179, cid: "cid", token: "token")) public final class GamifyWidgetSDK { internal static var widgetUrl: String = "" + internal static var adactUrl: String? + + private weak static var loyaltyViewController: GamifyWidgetViewController? + private weak static var adactViewController: GamifyWidgetViewController? private init() {} - /// Configure the widget URL before opening. - public static func initialize(widgetUrl: String) { - ensureMain { initialize_onMain(widgetUrl: widgetUrl) } + /// Configure the loyalty widget URL (and optional Adact host) before opening. + /// + /// - Parameters: + /// - widgetUrl: Loyalty widget base URL (may be empty if only Adact is used). + /// - adactUrl: Adact campaign host from config (trailing slash optional); + /// region-correct URL is supplied by onboarding / retrieval service. + public static func initialize(widgetUrl: String, adactUrl: String? = nil) { + ensureMain { initialize_onMain(widgetUrl: widgetUrl, adactUrl: adactUrl) } } - private static func initialize_onMain(widgetUrl: String) { + private static func initialize_onMain(widgetUrl: String, adactUrl: String?) { assertOnMainThread() self.widgetUrl = widgetUrl + self.adactUrl = Self.normalizeBaseUrl(adactUrl) } - /// Present the widget in a modal sheet. + /// Returns the configured Adact host with a trailing slash, or empty when unset. + public static func getAdactUrl() -> String { + guard let adactUrl = adactUrl, !adactUrl.isEmpty else { return "" } + return adactUrl + "/" + } + + /// Present the loyalty widget in a modal sheet. /// /// - Parameters: /// - viewController: The presenting UIViewController. @@ -43,15 +63,119 @@ public final class GamifyWidgetSDK { token: String? = nil ) { assertOnMainThread() - guard !widgetUrl.isEmpty, URL(string: widgetUrl) != nil else { + guard !widgetUrl.isEmpty, let url = URL(string: widgetUrl), url.scheme?.lowercased() == "https" else { Logger.error("GamifyWidgetSDK.open called with an invalid widgetUrl.") return } - let vc = GamifyWidgetViewController( - widgetUrl: widgetUrl, - userId: userId, - token: token - ) + if loyaltyViewController != nil { + return + } + + // Dismiss Adact first (if open), then present — avoids UIKit "presentation in progress" races. + dismissOverlay(adactViewController, clearing: { adactViewController = nil }) { + let vc = GamifyWidgetViewController( + widgetUrl: widgetUrl, + userId: userId, + token: token, + enableInitHandshake: true + ) + present(vc, from: viewController) + loyaltyViewController = vc + } + } + + /// Opens an Adact embedded campaign overlay. + /// Does not perform the loyalty READY→INIT handshake; identity is passed via URL query params. + public static func openAdactCampaign( + from viewController: UIViewController, + params: OpenAdactParams + ) { + ensureMain { openAdactCampaign_onMain(from: viewController, params: params) } + } + + private static func openAdactCampaign_onMain( + from viewController: UIViewController, + params: OpenAdactParams + ) { + assertOnMainThread() + let campaignUrl = buildAdactCampaignUrl(params: params) + guard !campaignUrl.isEmpty else { + Logger.error("GamifyWidgetSDK.openAdactCampaign called with invalid adactUrl or campaignId.") + return + } + if adactViewController != nil { + return + } + + dismissOverlay(loyaltyViewController, clearing: { loyaltyViewController = nil }) { + let vc = GamifyWidgetViewController( + widgetUrl: campaignUrl, + userId: nil, + token: nil, + enableInitHandshake: false + ) + present(vc, from: viewController) + adactViewController = vc + } + } + + /// Builds `{adactUrl}/embedded/{campaignId}?cid=&customerIdToken=`. + /// Returns empty string when `adactUrl` or `campaignId` is missing, or URL is not HTTPS. + public static func buildAdactCampaignUrl(params: OpenAdactParams) -> String { + guard let adactUrl = adactUrl, !adactUrl.isEmpty, let campaignId = params.campaignId else { + return "" + } + + var components = URLComponents(string: "\(adactUrl)/embedded/\(campaignId)") + guard let scheme = components?.scheme?.lowercased(), scheme == "https" else { + return "" + } + + var items: [URLQueryItem] = [] + if let cid = params.cid, !cid.isEmpty { + items.append(URLQueryItem(name: "cid", value: cid)) + } + if let token = params.token, !token.isEmpty { + items.append(URLQueryItem(name: "customerIdToken", value: token)) + } + if !items.isEmpty { + components?.queryItems = items + } + return components?.url?.absoluteString ?? "" + } + + public static func closeWidget() { + ensureMain { closeWidget_onMain() } + } + + private static func closeWidget_onMain() { + assertOnMainThread() + dismissOverlay(loyaltyViewController, clearing: { loyaltyViewController = nil }) + } + + public static func closeAdactCampaign() { + ensureMain { closeAdactCampaign_onMain() } + } + + private static func closeAdactCampaign_onMain() { + assertOnMainThread() + dismissOverlay(adactViewController, clearing: { adactViewController = nil }) + } + + private static func dismissOverlay( + _ overlay: GamifyWidgetViewController?, + clearing: () -> Void, + completion: (() -> Void)? = nil + ) { + guard let overlay = overlay else { + completion?() + return + } + clearing() + overlay.dismiss(animated: true, completion: completion) + } + + private static func present(_ vc: GamifyWidgetViewController, from presenter: UIViewController) { if #available(iOS 15.0, *) { if let sheet = vc.sheetPresentationController { sheet.detents = [.large()] @@ -60,7 +184,15 @@ public final class GamifyWidgetSDK { } else { vc.modalPresentationStyle = .pageSheet } - viewController.present(vc, animated: true) + presenter.present(vc, animated: true) + } + + internal static func normalizeBaseUrl(_ url: String?) -> String? { + guard let url = url, !url.isEmpty else { return nil } + if url.hasSuffix("/") { + return String(url.dropLast()) + } + return url } private static func ensureMain(_ work: @escaping () -> Void) { diff --git a/OptimoveSDK/Sources/Classes/GamifyWidget/GamifyWidgetViewController.swift b/OptimoveSDK/Sources/Classes/GamifyWidget/GamifyWidgetViewController.swift index a888da25..a77a8bac 100644 --- a/OptimoveSDK/Sources/Classes/GamifyWidget/GamifyWidgetViewController.swift +++ b/OptimoveSDK/Sources/Classes/GamifyWidget/GamifyWidgetViewController.swift @@ -13,14 +13,18 @@ final class GamifyWidgetViewController: UIViewController { private let widgetUrl: String private let userId: String? private let token: String? + private let enableInitHandshake: Bool private var webView: WKWebView! private var activityIndicator: UIActivityIndicatorView! - init(widgetUrl: String, userId: String?, token: String?) { + /// - Parameter enableInitHandshake: Loyalty widgets use READY→INIT. Adact campaigns pass + /// identity via URL query params and set this to `false`. + init(widgetUrl: String, userId: String?, token: String?, enableInitHandshake: Bool = true) { self.widgetUrl = widgetUrl self.userId = userId self.token = token + self.enableInitHandshake = enableInitHandshake super.init(nibName: nil, bundle: nil) } @@ -102,6 +106,18 @@ final class GamifyWidgetViewController: UIViewController { private func dismissSelf() { dismiss(animated: true) } + + private func parseMessageBody(_ body: Any) -> [String: Any]? { + if let dict = body as? [String: Any] { + return dict + } + if let string = body as? String, + let data = string.data(using: .utf8), + let dict = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + return dict + } + return nil + } } extension GamifyWidgetViewController: WKScriptMessageHandler { @@ -111,10 +127,17 @@ extension GamifyWidgetViewController: WKScriptMessageHandler { return } guard message.name == BridgeMessage.receiveMessage, - let body = message.body as? [String: Any], + let body = parseMessageBody(message.body), let type = body["type"] as? String else { return } - if type == "READY" { + + switch type { + case "READY": + guard enableInitHandshake else { return } DispatchQueue.main.async { self.sendInit() } + case "CLOSE": + DispatchQueue.main.async { self.dismissSelf() } + default: + break } } } diff --git a/OptimoveSDK/Sources/Classes/GamifyWidget/OpenAdactParams.swift b/OptimoveSDK/Sources/Classes/GamifyWidget/OpenAdactParams.swift new file mode 100644 index 00000000..414fba23 --- /dev/null +++ b/OptimoveSDK/Sources/Classes/GamifyWidget/OpenAdactParams.swift @@ -0,0 +1,19 @@ +// Copyright © 2026 Optimove. All rights reserved. + +import Foundation + +/// Parameters for opening an Adact embedded campaign. +/// +/// Maps to the Web / Android SDK `openAdactCampaign` contract: +/// `{adactUrl}/embedded/{campaignId}?cid=&customerIdToken=` +public struct OpenAdactParams { + public var campaignId: Int? + public var cid: String? + public var token: String? + + public init(campaignId: Int? = nil, cid: String? = nil, token: String? = nil) { + self.campaignId = campaignId + self.cid = cid + self.token = token + } +} diff --git a/OptimoveSDK/Tests/Sources/GamifyWidget/GamifyWidgetSDKTests.swift b/OptimoveSDK/Tests/Sources/GamifyWidget/GamifyWidgetSDKTests.swift index 13991e98..54fbd04d 100644 --- a/OptimoveSDK/Tests/Sources/GamifyWidget/GamifyWidgetSDKTests.swift +++ b/OptimoveSDK/Tests/Sources/GamifyWidget/GamifyWidgetSDKTests.swift @@ -14,7 +14,7 @@ final class GamifyWidgetSDKTests: XCTestCase { override func setUp() { super.setUp() runOnMainSync { - GamifyWidgetSDK.initialize(widgetUrl: "") + GamifyWidgetSDK.initialize(widgetUrl: "", adactUrl: nil) } } @@ -46,4 +46,76 @@ final class GamifyWidgetSDKTests: XCTestCase { } wait(for: [expectation], timeout: 2.0) } + + func testInitializeStoresNormalizedAdactUrl() { + runOnMainSync { + GamifyWidgetSDK.initialize( + widgetUrl: "https://loyalty.example.com", + adactUrl: "https://campaign.adact.me/" + ) + } + XCTAssertEqual(GamifyWidgetSDK.adactUrl, "https://campaign.adact.me") + XCTAssertEqual(GamifyWidgetSDK.getAdactUrl(), "https://campaign.adact.me/") + } + + func testGetAdactUrlEmptyWhenNotConfigured() { + runOnMainSync { + GamifyWidgetSDK.initialize(widgetUrl: "https://loyalty.example.com") + } + XCTAssertEqual(GamifyWidgetSDK.getAdactUrl(), "") + } + + func testBuildAdactCampaignUrlBuildsEmbeddedPath() { + runOnMainSync { + GamifyWidgetSDK.initialize(widgetUrl: "", adactUrl: "https://campaign.adact.me/") + } + XCTAssertEqual( + GamifyWidgetSDK.buildAdactCampaignUrl(params: OpenAdactParams(campaignId: 179)), + "https://campaign.adact.me/embedded/179" + ) + } + + func testBuildAdactCampaignUrlAddsCidAndCustomerIdToken() { + runOnMainSync { + GamifyWidgetSDK.initialize(widgetUrl: "", adactUrl: "https://campaign.adact.me/") + } + let url = GamifyWidgetSDK.buildAdactCampaignUrl( + params: OpenAdactParams( + campaignId: 179, + cid: "customer@example.com", + token: "jwt-token" + ) + ) + XCTAssertTrue(url.hasPrefix("https://campaign.adact.me/embedded/179?")) + XCTAssertTrue(url.contains("cid=customer%40example.com") || url.contains("cid=customer@example.com")) + XCTAssertTrue(url.contains("customerIdToken=jwt-token")) + } + + func testBuildAdactCampaignUrlEmptyWhenAdactUrlOrCampaignIdMissing() { + runOnMainSync { + GamifyWidgetSDK.initialize(widgetUrl: "https://loyalty.example.com") + } + XCTAssertEqual( + GamifyWidgetSDK.buildAdactCampaignUrl(params: OpenAdactParams(campaignId: 179)), + "" + ) + + runOnMainSync { + GamifyWidgetSDK.initialize(widgetUrl: "", adactUrl: "https://campaign.adact.me/") + } + XCTAssertEqual( + GamifyWidgetSDK.buildAdactCampaignUrl(params: OpenAdactParams(cid: "cid")), + "" + ) + } + + func testBuildAdactCampaignUrlRejectsNonHttps() { + runOnMainSync { + GamifyWidgetSDK.initialize(widgetUrl: "", adactUrl: "http://campaign.adact.me/") + } + XCTAssertEqual( + GamifyWidgetSDK.buildAdactCampaignUrl(params: OpenAdactParams(campaignId: 179)), + "" + ) + } }