diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e6840f1..7994a2ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 6.7.0 + +- Add Gamify Widget SDK — `GamifyWidgetSDK.initialize(widgetUrl:)` and `GamifyWidgetSDK.open(from:userId:token:)` present the widget in a modal `WKWebView` with a JS bridge for `READY` / `INIT` / `CLOSE` handshakes. + ## 6.6.0 - Implementation for Overlay Messaging channel. Check optimove developer docs for more. diff --git a/OptimoveCore.podspec b/OptimoveCore.podspec index f03657b3..916cae46 100644 --- a/OptimoveCore.podspec +++ b/OptimoveCore.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'OptimoveCore' - s.version = '6.6.0' + s.version = '6.7.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 db7ff256..262886dc 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.6.0" +public let SDKVersion = "6.7.0" diff --git a/OptimoveNotificationServiceExtension.podspec b/OptimoveNotificationServiceExtension.podspec index 4cbd8c7d..dec859d9 100644 --- a/OptimoveNotificationServiceExtension.podspec +++ b/OptimoveNotificationServiceExtension.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'OptimoveNotificationServiceExtension' - s.version = '6.6.0' + s.version = '6.7.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 25878c00..68529101 100644 --- a/OptimoveSDK.podspec +++ b/OptimoveSDK.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'OptimoveSDK' - s.version = '6.6.0' + s.version = '6.7.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 new file mode 100644 index 00000000..6bfa9459 --- /dev/null +++ b/OptimoveSDK/Sources/Classes/GamifyWidget/GamifyWidgetSDK.swift @@ -0,0 +1,77 @@ +// Copyright © 2026 Optimove. All rights reserved. + +import UIKit + +/// Entry point for the Gamify Widget SDK. +/// +/// Usage: +/// GamifyWidgetSDK.initialize(widgetUrl: "https://your-widget.example.com") +/// GamifyWidgetSDK.open(from: viewController, userId: "u123") +public final class GamifyWidgetSDK { + + internal static var widgetUrl: String = "" + + private init() {} + + /// Configure the widget URL before opening. + public static func initialize(widgetUrl: String) { + ensureMain { initialize_onMain(widgetUrl: widgetUrl) } + } + + private static func initialize_onMain(widgetUrl: String) { + assertOnMainThread() + self.widgetUrl = widgetUrl + } + + /// Present the widget in a modal sheet. + /// + /// - Parameters: + /// - viewController: The presenting UIViewController. + /// - userId: Optional user ID injected via INIT handshake. + /// - token: Optional auth token injected via INIT handshake. + public static func open( + from viewController: UIViewController, + userId: String? = nil, + token: String? = nil + ) { + ensureMain { open_onMain(from: viewController, userId: userId, token: token) } + } + + private static func open_onMain( + from viewController: UIViewController, + userId: String? = nil, + token: String? = nil + ) { + assertOnMainThread() + guard !widgetUrl.isEmpty, URL(string: widgetUrl) != nil else { + Logger.error("GamifyWidgetSDK.open called with an invalid widgetUrl.") + return + } + let vc = GamifyWidgetViewController( + widgetUrl: widgetUrl, + userId: userId, + token: token + ) + if #available(iOS 15.0, *) { + if let sheet = vc.sheetPresentationController { + sheet.detents = [.large()] + sheet.prefersGrabberVisible = true + } + } else { + vc.modalPresentationStyle = .pageSheet + } + viewController.present(vc, animated: true) + } + + private static func ensureMain(_ work: @escaping () -> Void) { + if Thread.isMainThread { + work() + } else { + DispatchQueue.main.async(execute: work) + } + } + + private static func assertOnMainThread(_ message: String = "Must be on main thread") { + assert(Thread.isMainThread, message) + } +} diff --git a/OptimoveSDK/Sources/Classes/GamifyWidget/GamifyWidgetViewController.swift b/OptimoveSDK/Sources/Classes/GamifyWidget/GamifyWidgetViewController.swift new file mode 100644 index 00000000..a888da25 --- /dev/null +++ b/OptimoveSDK/Sources/Classes/GamifyWidget/GamifyWidgetViewController.swift @@ -0,0 +1,134 @@ +// Copyright © 2026 Optimove. All rights reserved. + +import UIKit +import WebKit + +private enum BridgeMessage { + static let receiveMessage = "receiveMessage" + static let closeWidget = "closeWidget" +} + +final class GamifyWidgetViewController: UIViewController { + + private let widgetUrl: String + private let userId: String? + private let token: String? + + private var webView: WKWebView! + private var activityIndicator: UIActivityIndicatorView! + + init(widgetUrl: String, userId: String?, token: String?) { + self.widgetUrl = widgetUrl + self.userId = userId + self.token = token + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { fatalError() } + + override func viewDidLoad() { + super.viewDidLoad() + if #available(iOS 13.0, *) { + view.backgroundColor = .systemBackground + } else { + view.backgroundColor = .white + } + setupWebView() + setupLoadingIndicator() + loadWidget() + } + + private func setupWebView() { + let contentController = WKUserContentController() + contentController.add(self, name: BridgeMessage.receiveMessage) + contentController.add(self, name: BridgeMessage.closeWidget) + + let config = WKWebViewConfiguration() + config.userContentController = contentController + + webView = WKWebView(frame: .zero, configuration: config) + webView.navigationDelegate = self + webView.translatesAutoresizingMaskIntoConstraints = false + + view.addSubview(webView) + NSLayoutConstraint.activate([ + webView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + webView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + webView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + webView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor), + ]) + } + + private func setupLoadingIndicator() { + if #available(iOS 13.0, *) { + activityIndicator = UIActivityIndicatorView(style: .medium) + } else { + activityIndicator = UIActivityIndicatorView(style: .gray) + } + activityIndicator.translatesAutoresizingMaskIntoConstraints = false + activityIndicator.hidesWhenStopped = true + view.addSubview(activityIndicator) + NSLayoutConstraint.activate([ + activityIndicator.centerXAnchor.constraint(equalTo: view.centerXAnchor), + activityIndicator.centerYAnchor.constraint(equalTo: view.centerYAnchor), + ]) + activityIndicator.startAnimating() + } + + private func loadWidget() { + guard let url = URL(string: widgetUrl) else { + dismissSelf() + return + } + webView.load(URLRequest(url: url)) + } + + private func sendInit() { + var payload: [String: Any] = ["type": "INIT"] + if let userId = userId { payload["userId"] = userId } + if let token = token { payload["token"] = token } + guard let data = try? JSONSerialization.data(withJSONObject: payload), + let json = String(data: data, encoding: .utf8) else { return } + Logger.debug("GamifyWidget sending INIT: \(redactedLog(payload))") + webView.evaluateJavaScript("window.postMessage(\(json), '*');", completionHandler: nil) + } + + private func redactedLog(_ payload: [String: Any]) -> String { + var redacted = payload + if redacted["token"] != nil { redacted["token"] = "[REDACTED]" } + return "\(redacted)" + } + + private func dismissSelf() { + dismiss(animated: true) + } +} + +extension GamifyWidgetViewController: WKScriptMessageHandler { + func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { + if message.name == BridgeMessage.closeWidget { + DispatchQueue.main.async { self.dismissSelf() } + return + } + guard message.name == BridgeMessage.receiveMessage, + let body = message.body as? [String: Any], + let type = body["type"] as? String else { return } + if type == "READY" { + DispatchQueue.main.async { self.sendInit() } + } + } +} + +extension GamifyWidgetViewController: WKNavigationDelegate { + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + activityIndicator.stopAnimating() + } + + func webView(_: WKWebView, didFail _: WKNavigation!, withError _: Error) { + dismissSelf() + } + + func webView(_: WKWebView, didFailProvisionalNavigation _: WKNavigation!, withError _: Error) { + dismissSelf() + } +} diff --git a/OptimoveSDK/Tests/Sources/GamifyWidget/GamifyWidgetSDKTests.swift b/OptimoveSDK/Tests/Sources/GamifyWidget/GamifyWidgetSDKTests.swift new file mode 100644 index 00000000..13991e98 --- /dev/null +++ b/OptimoveSDK/Tests/Sources/GamifyWidget/GamifyWidgetSDKTests.swift @@ -0,0 +1,49 @@ +import XCTest +@testable import OptimoveSDK + +final class GamifyWidgetSDKTests: XCTestCase { + + private func runOnMainSync(_ block: @escaping () -> Void) { + if Thread.isMainThread { + block() + } else { + DispatchQueue.main.sync(execute: block) + } + } + + override func setUp() { + super.setUp() + runOnMainSync { + GamifyWidgetSDK.initialize(widgetUrl: "") + } + } + + func testInitializeSetsWidgetUrl() { + let url = "https://gamify-widget.example.com" + runOnMainSync { + GamifyWidgetSDK.initialize(widgetUrl: url) + } + XCTAssertEqual(GamifyWidgetSDK.widgetUrl, url) + } + + func testInitializeOverwritesPreviousUrl() { + runOnMainSync { + GamifyWidgetSDK.initialize(widgetUrl: "https://first.example.com") + GamifyWidgetSDK.initialize(widgetUrl: "https://second.example.com") + } + XCTAssertEqual(GamifyWidgetSDK.widgetUrl, "https://second.example.com") + } + + func testInitializeFromBackgroundThreadDispatchesToMain() { + let url = "https://background.example.com" + let expectation = expectation(description: "widgetUrl set from background call") + DispatchQueue.global().async { + GamifyWidgetSDK.initialize(widgetUrl: url) + DispatchQueue.main.async { + XCTAssertEqual(GamifyWidgetSDK.widgetUrl, url) + expectation.fulfill() + } + } + wait(for: [expectation], timeout: 2.0) + } +}