-
Notifications
You must be signed in to change notification settings - Fork 5
AB#280444 - Add GamifyWidget SDK module #140
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
dmytro-b-optimove
wants to merge
11
commits into
master
Choose a base branch
from
feature/280444-gamify-widget-ios
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
11 commits
Select commit
Hold shift + click to select a range
15e16c8
AB#280444 - gamify widget add GamifyWidgetSDK and view controller
dmytro-b-optimove b090425
AB#280444 - gamify widget fix iOS bridge handler names to match widge…
dmytro-b-optimove 36b6a0b
AB#280444 - gamify widget fix iOS 12 availability for UIKit APIs
dmytro-b-optimove da1effe
AB#280444 - gamify widget present vc directly without nav controller …
dmytro-b-optimove 7bbab3c
AB#280444 - gamify widget address PR review comments
dmytro-b-optimove 9a145a1
AB#280444 - gamify widget assert main thread on SDK entry points
dmytro-b-optimove 4d3cf1b
Merge remote-tracking branch 'origin/master' into feature/280444-gami…
dmytro-b-optimove 13783a4
AB#280444 - gamify widget auto-dispatch SDK entry points to main thread
dmytro-b-optimove 3719c18
AB#280444 - gamify widget fail-fast on invalid widgetUrl, drop in-vie…
dmytro-b-optimove d9099e7
AB#280444 - gamify widget force initialize calls onto main in tests
dmytro-b-optimove bc665a6
AB#280444 - gamify widget bump SDK version to 6.7.0
dmytro-b-optimove File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,3 +1,3 @@ | ||
| // Copyright © 2019 Optimove. All rights reserved. | ||
|
|
||
| public let SDKVersion = "6.6.0" | ||
| public let SDKVersion = "6.7.0" |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
77 changes: 77 additions & 0 deletions
77
OptimoveSDK/Sources/Classes/GamifyWidget/GamifyWidgetSDK.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| ) | ||
|
dmytro-b-optimove marked this conversation as resolved.
|
||
| 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) | ||
|
eligutovsky marked this conversation as resolved.
|
||
| } | ||
| } | ||
134 changes: 134 additions & 0 deletions
134
OptimoveSDK/Sources/Classes/GamifyWidget/GamifyWidgetViewController.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,134 @@ | ||
| // Copyright © 2026 Optimove. All rights reserved. | ||
|
|
||
| import UIKit | ||
| import WebKit | ||
|
dmytro-b-optimove marked this conversation as resolved.
|
||
|
|
||
| private enum BridgeMessage { | ||
| static let receiveMessage = "receiveMessage" | ||
| static let closeWidget = "closeWidget" | ||
| } | ||
|
dmytro-b-optimove marked this conversation as resolved.
|
||
|
|
||
| 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) | ||
|
graciecooper marked this conversation as resolved.
|
||
| contentController.add(self, name: BridgeMessage.closeWidget) | ||
|
|
||
| let config = WKWebViewConfiguration() | ||
| config.userContentController = contentController | ||
|
|
||
|
dmytro-b-optimove marked this conversation as resolved.
|
||
| 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() | ||
| } | ||
| } | ||
49 changes: 49 additions & 0 deletions
49
OptimoveSDK/Tests/Sources/GamifyWidget/GamifyWidgetSDKTests.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) | ||
| } | ||
| } |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.