diff --git a/CHANGELOG.md b/CHANGELOG.md index c37715008a..15d8441dda 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/superwall/Superwall-iOS/releases) on GitHub. +## 4.15.1 + +### Enhancements + +- Adds an `onCustomCallback` parameter to `getPaywall`. +- `SuperwallOptions.localResources` now accepts UIImage's from xcasset files, e.g. `UIImage(named: "my-image")`. +- Exposes abandoned transaction product params in audience filters. + +### Fixes + +- Sanitizes email user attribute. + ## 4.15.0 ### Enhancements diff --git a/Examples/Advanced/Advanced.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Examples/Advanced/Advanced.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 8dcbeeb950..c9e34050a5 100644 --- a/Examples/Advanced/Advanced.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Examples/Advanced/Advanced.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -6,8 +6,8 @@ "repositoryURL": "https://github.com/RevenueCat/purchases-ios.git", "state": { "branch": null, - "revision": "155ea739f45f54189ca83ee9088b373c1415d98b", - "version": "5.64.0" + "revision": "a06accc1543fcedc3094d4b5b9a40b84e2213e8e", + "version": "5.69.0" } }, { diff --git a/Examples/Basic/Basic.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Examples/Basic/Basic.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 3345860464..251dbbdc99 100644 --- a/Examples/Basic/Basic.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Examples/Basic/Basic.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -6,8 +6,8 @@ "repositoryURL": "https://github.com/superwall/Superscript-iOS", "state": { "branch": null, - "revision": "ce546d7ad70b5ce5f66ea0caffefb0d97be49f34", - "version": "1.0.12" + "revision": "711866edcb62dbd237c24ed3e5fa39fad0db639f", + "version": "1.0.13" } } ] diff --git a/Sources/SuperwallKit/Analytics/Internal Tracking/Trackable Events/TrackableSuperwallEvent.swift b/Sources/SuperwallKit/Analytics/Internal Tracking/Trackable Events/TrackableSuperwallEvent.swift index 97dbd5a3fa..a6cdf95003 100644 --- a/Sources/SuperwallKit/Analytics/Internal Tracking/Trackable Events/TrackableSuperwallEvent.swift +++ b/Sources/SuperwallKit/Analytics/Internal Tracking/Trackable Events/TrackableSuperwallEvent.swift @@ -671,6 +671,9 @@ enum InternalSuperwallEvent { var params = paywallInfo.audienceFilterParams() if let product = product { params["abandoned_product_id"] = product.productIdentifier + for (key, value) in product.attributes where key != "identifier" { + params["abandoned_product_\(key.camelCaseToSnakeCase())"] = value + } } return params default: diff --git a/Sources/SuperwallKit/Config/ConfigLogic.swift b/Sources/SuperwallKit/Config/ConfigLogic.swift index 15b6647765..8b0075ff6f 100644 --- a/Sources/SuperwallKit/Config/ConfigLogic.swift +++ b/Sources/SuperwallKit/Config/ConfigLogic.swift @@ -306,8 +306,7 @@ enum ConfigLogic { from config: Config ) -> [String: Set] { return Dictionary( - config.products.map { ($0.id, $0.entitlements) }, - uniquingKeysWith: { $0.union($1) } - ) + config.products.map { ($0.id, $0.entitlements) } + ) { $0.union($1) } } } diff --git a/Sources/SuperwallKit/Config/Options/AssetResource.swift b/Sources/SuperwallKit/Config/Options/AssetResource.swift new file mode 100644 index 0000000000..bf35e8ef1f --- /dev/null +++ b/Sources/SuperwallKit/Config/Options/AssetResource.swift @@ -0,0 +1,33 @@ +// +// AssetResource.swift +// SuperwallKit +// +// Created by Yusuf Tör on 24/04/2026. +// + +import Foundation +#if canImport(UIKit) +import UIKit +#endif + +/// A type that can be registered against ``SuperwallOptions/localResources`` and +/// served to the paywall webview via the `swlocal://` URL scheme. +/// +/// Conforming types: +/// - `URL` — a file on disk. +/// - `UIImage` — re-encoded as PNG when served to the webview. Use this to +/// register an asset catalog Image Set: `UIImage(named: "Logo")!`. +/// +/// ```swift +/// options.localResources = [ +/// "hero-image": Bundle.main.url(forResource: "hero", withExtension: "png")!, +/// "logo": UIImage(named: "Logo")! +/// ] +/// ``` +public protocol AssetResource {} + +extension URL: AssetResource {} + +#if canImport(UIKit) +extension UIImage: AssetResource {} +#endif diff --git a/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift b/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift index c86f925762..f7d42bbeda 100644 --- a/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift +++ b/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift @@ -4,25 +4,33 @@ // // Created by Yusuf Tör on 11/07/2022. // +// swiftlint:disable file_length import Foundation +#if canImport(UIKit) +import UIKit +#endif /// Options for configuring Superwall, including paywall presentation and appearance. /// /// Pass an instance of this class to /// ``Superwall/configure(apiKey:purchaseController:options:completion:)-52tke``. +// swiftlint:disable type_body_length @objc(SWKSuperwallOptions) @objcMembers public final class SuperwallOptions: NSObject, Encodable { /// Configures the appearance and behaviour of paywalls. public var paywalls = PaywallOptions() - /// A mapping of local resource IDs to local file URLs. + /// A mapping of local resource IDs to ``AssetResource`` values. /// - /// Use this to serve paywall assets (images, videos, Lottie animations) from local files - /// instead of fetching them over the network. When a paywall references a `localResourceId`, - /// the SDK will look up the corresponding URL in this dictionary and serve the file via the - /// `swlocal://` URL scheme. + /// Use this to serve paywall assets (images, videos, Lottie animations) from the app + /// bundle or an asset catalog instead of fetching them over the network. When a paywall + /// references a `localResourceId`, the SDK looks up the corresponding entry here and + /// serves it via the `swlocal://` URL scheme. + /// + /// `URL` conforms to ``AssetResource`` so file-URL call sites keep working. Register an + /// `.xcassets` Image Set by passing a `UIImage`. /// /// Set this before calling ``Superwall/configure(apiKey:purchaseController:options:completion:)-52tke`` /// to ensure resources are available before any paywall can trigger (e.g. on `app_launch`). @@ -30,12 +38,45 @@ public final class SuperwallOptions: NSObject, Encodable { /// ```swift /// let options = SuperwallOptions() /// options.localResources = [ - /// "hero-video": Bundle.main.url(forResource: "onboarding", withExtension: "mp4")!, + /// "logo": UIImage(named: "Logo")!, /// "hero-image": Bundle.main.url(forResource: "hero", withExtension: "png")! /// ] /// Superwall.configure(apiKey: "your-api-key", options: options) /// ``` - public var localResources: [String: URL] = [:] + @nonobjc public var localResources: [String: AssetResource] = [:] + + /// Objective-C bridge for ``localResources``. Accepts `NSURL` and `UIImage` values + /// (mirroring the Swift surface); any other value type is dropped. + @available(swift, obsoleted: 1.0) + @objc(localResources) + public var localResourcesObjC: [String: NSObject] { + get { + return localResources.compactMapValues { resource in + if let url = resource as? URL { + return url as NSURL + } + #if canImport(UIKit) + if let image = resource as? UIImage { + return image + } + #endif + return nil + } + } + set { + localResources = newValue.compactMapValues { value in + if let url = value as? URL { + return url + } + #if canImport(UIKit) + if let image = value as? UIImage { + return image + } + #endif + return nil + } + } + } /// Controls when the SDK enters test mode. @objc(SWKTestModeBehavior) diff --git a/Sources/SuperwallKit/Debug/SWLocalResourcesViewController.swift b/Sources/SuperwallKit/Debug/SWLocalResourcesViewController.swift index 54753bd4c9..9ae9ee9226 100644 --- a/Sources/SuperwallKit/Debug/SWLocalResourcesViewController.swift +++ b/Sources/SuperwallKit/Debug/SWLocalResourcesViewController.swift @@ -7,7 +7,7 @@ import UIKit import AVFoundation final class SWLocalResourcesViewController: UICollectionViewController { - private var resources: [(id: String, url: URL)] = [] + private var resources: [(id: String, resource: AssetResource)] = [] init() { let layout = UICollectionViewFlowLayout() @@ -53,7 +53,7 @@ final class SWLocalResourcesViewController: UICollectionViewController { resources = Superwall.shared.options.localResources .sorted { $0.key < $1.key } - .map { (id: $0.key, url: $0.value) } + .map { (id: $0.key, resource: $0.value) } } @objc private func doneTapped() { @@ -86,7 +86,7 @@ final class SWLocalResourcesViewController: UICollectionViewController { // swiftlint:disable:next force_cast ) as! LocalResourceCell let resource = resources[indexPath.item] - cell.configure(id: resource.id, url: resource.url) + cell.configure(id: resource.id, resource: resource.resource) return cell } } @@ -231,7 +231,20 @@ private final class LocalResourceCell: UICollectionViewCell { spinner.stopAnimating() } - func configure(id: String, url: URL) { + func configure(id: String, resource: AssetResource) { + if let url = resource as? URL { + configureURL(id: id, url: url) + } else if let image = resource as? UIImage { + idLabel.text = "\(id) (UIImage)" + spinner.stopAnimating() + imageView.image = image + } else { + idLabel.text = id + showErrorText("Unsupported resource type") + } + } + + private func configureURL(id: String, url: URL) { let ext = url.pathExtension.lowercased() idLabel.text = ext.isEmpty ? id : "\(id).\(ext)" spinner.startAnimating() diff --git a/Sources/SuperwallKit/Dependencies/DependencyContainer.swift b/Sources/SuperwallKit/Dependencies/DependencyContainer.swift index 125a768743..8b61915fc5 100644 --- a/Sources/SuperwallKit/Dependencies/DependencyContainer.swift +++ b/Sources/SuperwallKit/Dependencies/DependencyContainer.swift @@ -48,11 +48,13 @@ final class DependencyContainer { let paywallArchiveManager = PaywallArchiveManager() init( + apiKey: String = "", purchaseController controller: PurchaseController? = nil, options: SuperwallOptions? = nil ) { delegateAdapter = SuperwallDelegateAdapter() storage = Storage(factory: self) + storage.configure(apiKey: apiKey) entitlementsInfo = EntitlementsInfo( storage: storage, delegateAdapter: delegateAdapter @@ -312,7 +314,8 @@ extension DependencyContainer: ViewControllerFactory { webView: webView, webEntitlementRedeemer: webEntitlementRedeemer, cache: cache, - paywallArchiveManager: paywallArchiveManager + paywallArchiveManager: paywallArchiveManager, + customCallbackRegistry: customCallbackRegistry ) webView.delegate = paywallViewController diff --git a/Sources/SuperwallKit/Graveyard/SuperwallGraveyard.swift b/Sources/SuperwallKit/Graveyard/SuperwallGraveyard.swift index e4025d3002..7607474530 100644 --- a/Sources/SuperwallKit/Graveyard/SuperwallGraveyard.swift +++ b/Sources/SuperwallKit/Graveyard/SuperwallGraveyard.swift @@ -107,7 +107,8 @@ extension Superwall { ), webEntitlementRedeemer: dependencyContainer.webEntitlementRedeemer, cache: dependencyContainer.makeCache(), - paywallArchiveManager: dependencyContainer.paywallArchiveManager + paywallArchiveManager: dependencyContainer.paywallArchiveManager, + customCallbackRegistry: dependencyContainer.customCallbackRegistry ) } diff --git a/Sources/SuperwallKit/Identity/Email.swift b/Sources/SuperwallKit/Identity/Email.swift new file mode 100644 index 0000000000..6dd5ff368b --- /dev/null +++ b/Sources/SuperwallKit/Identity/Email.swift @@ -0,0 +1,32 @@ +// +// Email.swift +// SuperwallKit +// + +import Foundation + +/// A validated email address. +/// +/// The failable initializer rejects any string that does not match the +/// pattern expected by the checkout API (`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}\z`). +/// Holding an `Email` instance proves the value was validated — downstream +/// code never needs to re-check. +struct Email: Equatable, Sendable { + let rawValue: String + + // `\z` (not `$`) is used so that a trailing `\n` is rejected — ICU treats `$` + // as matching at end-of-string *or* just before a final newline. + // Pattern is a validated literal — initialization can never throw at runtime. + private static let regex = try! NSRegularExpression( // swiftlint:disable:this force_try + pattern: #"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}\z"# + ) + + /// Returns `nil` when `rawValue` is not a syntactically valid email address. + init?(_ rawValue: String) { + let range = NSRange(rawValue.startIndex..., in: rawValue) + guard Self.regex.firstMatch(in: rawValue, range: range) != nil else { + return nil + } + self.rawValue = rawValue + } +} diff --git a/Sources/SuperwallKit/Identity/UserAttributes.swift b/Sources/SuperwallKit/Identity/UserAttributes.swift index f3e390c4ac..174b55907e 100644 --- a/Sources/SuperwallKit/Identity/UserAttributes.swift +++ b/Sources/SuperwallKit/Identity/UserAttributes.swift @@ -94,11 +94,39 @@ extension Superwall { continue } if JSONSerialization.isValidJSONObject([key: value]) { - customAttributes[key] = value + customAttributes[key] = Self.sanitizeAttribute(key: key, value: value) } } } dependencyContainer.identityManager.mergeUserAttributes(customAttributes) } + + /// Validates attribute values that have server-side schema constraints. + /// + /// The checkout API rejects `context.identity.email` unless it is either a + /// valid email address or `null`. Apps that set a placeholder like `"none"` + /// would silently break the Stripe checkout flow, so the SDK parses the + /// value through ``Email`` and drops it when invalid. + private static func sanitizeAttribute(key: String, value: Any?) -> Any? { + guard let stringValue = value as? String else { + return value + } + + switch key { + case "email": + guard let email = Email(stringValue) else { + Logger.debug( + logLevel: .warn, + scope: .identityManager, + message: "Invalid email user attribute — sending null to server" + ) + return nil + } + return email.rawValue + + default: + return value + } + } } diff --git a/Sources/SuperwallKit/Misc/Constants.swift b/Sources/SuperwallKit/Misc/Constants.swift index 19f2179977..03b9d70246 100644 --- a/Sources/SuperwallKit/Misc/Constants.swift +++ b/Sources/SuperwallKit/Misc/Constants.swift @@ -18,5 +18,5 @@ let sdkVersion = """ */ let sdkVersion = """ -4.15.0 +4.15.1 """ diff --git a/Sources/SuperwallKit/Paywall/Presentation/Get Paywall/PublicGetPaywall.swift b/Sources/SuperwallKit/Paywall/Presentation/Get Paywall/PublicGetPaywall.swift index e33af687be..5feb088e55 100644 --- a/Sources/SuperwallKit/Paywall/Presentation/Get Paywall/PublicGetPaywall.swift +++ b/Sources/SuperwallKit/Paywall/Presentation/Get Paywall/PublicGetPaywall.swift @@ -24,6 +24,9 @@ extension Superwall { /// be dropped. /// - paywallOverrides: An optional ``PaywallOverrides`` object whose parameters override the paywall defaults. Use this to override products and presentation style. Defaults to `nil`. /// - delegate: A delegate responsible for handling user interactions with the retrieved ``PaywallViewController``. + /// - onCustomCallback: An optional async block invoked when the paywall webview triggers a custom callback. + /// Return a ``CustomCallbackResult`` to report success or failure back to the webview. Defaults to `nil`, in which + /// case the webview receives a failure result for any custom callbacks. /// - completion: A completion block accepting an optional ``PaywallViewController``, an optional /// ``PaywallSkippedReason`` and an optional `Error`. If the ``PaywallViewController`` couldn't be retrieved /// because its presentation should be skipped, the ``PaywallSkippedReason`` will be non-`nil`. Any errors @@ -35,6 +38,7 @@ extension Superwall { params: [String: Any]? = nil, paywallOverrides: PaywallOverrides? = nil, delegate: PaywallViewControllerDelegate, + onCustomCallback: ((CustomCallback) async -> CustomCallbackResult)? = nil, completion: @escaping (PaywallViewController?, PaywallSkippedReason?, Error?) -> Void ) { Task { @MainActor in @@ -43,7 +47,8 @@ extension Superwall { forPlacement: placement, params: params, paywallOverrides: paywallOverrides, - delegate: delegate + delegate: delegate, + onCustomCallback: onCustomCallback ) completion(paywallViewController, nil, nil) } catch let reason as PaywallSkippedReason { @@ -68,6 +73,9 @@ extension Superwall { /// be dropped. /// - paywallOverrides: An optional ``PaywallOverrides`` object whose parameters override the paywall defaults. Use this to override products and presentation style. Defaults to `nil`. /// - delegate: A delegate responsible for handling user interactions with the retrieved ``PaywallViewController``. + /// - onCustomCallback: An optional async block invoked when the paywall webview triggers a custom callback. + /// Return a ``CustomCallbackResult`` to report success or failure back to the webview. Defaults to `nil`, in which + /// case the webview receives a failure result for any custom callbacks. /// /// - Returns: A ``PaywallViewController`` object. /// - Throws: An `Error` explaining why it couldn't get the view controller. If the ``PaywallViewController`` couldn't be retrieved @@ -80,7 +88,8 @@ extension Superwall { forPlacement placement: String, params: [String: Any]? = nil, paywallOverrides: PaywallOverrides? = nil, - delegate: PaywallViewControllerDelegate + delegate: PaywallViewControllerDelegate, + onCustomCallback: ((CustomCallback) async -> CustomCallbackResult)? = nil ) async throws -> PaywallViewController { return try await internallyGetPaywall( forPlacement: placement, @@ -88,7 +97,8 @@ extension Superwall { paywallOverrides: paywallOverrides, delegate: .init( swiftDelegate: delegate, - objcDelegate: nil + objcDelegate: nil, + onCustomCallback: onCustomCallback ) ) } diff --git a/Sources/SuperwallKit/Paywall/View Controller/Delegates/PaywallViewControllerDelegateAdapter.swift b/Sources/SuperwallKit/Paywall/View Controller/Delegates/PaywallViewControllerDelegateAdapter.swift index 1a995ead05..e79d4e8a6a 100644 --- a/Sources/SuperwallKit/Paywall/View Controller/Delegates/PaywallViewControllerDelegateAdapter.swift +++ b/Sources/SuperwallKit/Paywall/View Controller/Delegates/PaywallViewControllerDelegateAdapter.swift @@ -12,16 +12,21 @@ final class PaywallViewControllerDelegateAdapter { weak var swiftDelegate: PaywallViewControllerDelegate? weak var objcDelegate: PaywallViewControllerDelegateObjc? + /// An optional handler invoked when the paywall webview triggers a custom callback. + let onCustomCallback: ((CustomCallback) async -> CustomCallbackResult)? + var hasObjcDelegate: Bool { return objcDelegate != nil } init( swiftDelegate: PaywallViewControllerDelegate?, - objcDelegate: PaywallViewControllerDelegateObjc? + objcDelegate: PaywallViewControllerDelegateObjc?, + onCustomCallback: ((CustomCallback) async -> CustomCallbackResult)? = nil ) { self.swiftDelegate = swiftDelegate self.objcDelegate = objcDelegate + self.onCustomCallback = onCustomCallback } @MainActor diff --git a/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift b/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift index 32cff16239..9353a7b2bd 100644 --- a/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift +++ b/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift @@ -71,7 +71,11 @@ public class PaywallViewController: UIViewController, LoadingDelegate { } } - var delegate: PaywallViewControllerDelegateAdapter? + var delegate: PaywallViewControllerDelegateAdapter? { + didSet { + syncCustomCallbackRegistration() + } + } typealias Factory = TriggerFactory & RestoreAccessFactory @@ -215,6 +219,7 @@ public class PaywallViewController: UIViewController, LoadingDelegate { private unowned let storage: Storage private unowned let deviceHelper: DeviceHelper private unowned let webEntitlementRedeemer: WebEntitlementRedeemer + private unowned let customCallbackRegistry: CustomCallbackRegistry private weak var cache: PaywallViewControllerCache? private weak var paywallArchiveManager: PaywallArchiveManager? @@ -231,7 +236,8 @@ public class PaywallViewController: UIViewController, LoadingDelegate { webView: SWWebView, webEntitlementRedeemer: WebEntitlementRedeemer, cache: PaywallViewControllerCache?, - paywallArchiveManager: PaywallArchiveManager? + paywallArchiveManager: PaywallArchiveManager?, + customCallbackRegistry: CustomCallbackRegistry ) { self.cache = cache self.paywallArchiveManager = paywallArchiveManager @@ -248,15 +254,38 @@ public class PaywallViewController: UIViewController, LoadingDelegate { self.webView = webView self.introOfferTokenManager = IntroOfferTokenManager(network: network) self.webEntitlementRedeemer = webEntitlementRedeemer + self.customCallbackRegistry = customCallbackRegistry presentationStyle = paywall.presentation.style super.init(nibName: nil, bundle: nil) + syncCustomCallbackRegistration() } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + /// Registers or unregisters the delegate's custom callback handler with + /// ``CustomCallbackRegistry`` so the paywall webview can invoke it. + /// + /// `register()` manages registration around its own present/dismiss lifecycle, so this + /// path is the one that wires up handlers supplied via + /// ``Superwall/getPaywall(forPlacement:params:paywallOverrides:delegate:onCustomCallback:)``. + private var hasRegisteredCallback = false + + private func syncCustomCallbackRegistration() { + if let handler = delegate?.onCustomCallback { + customCallbackRegistry.register( + paywallIdentifier: paywall.identifier, + handler: handler + ) + hasRegisteredCallback = true + } else if hasRegisteredCallback { + customCallbackRegistry.unregister(paywallIdentifier: paywall.identifier) + hasRegisteredCallback = false + } + } + public override func viewDidLoad() { super.viewDidLoad() configureUI() @@ -266,6 +295,9 @@ public class PaywallViewController: UIViewController, LoadingDelegate { deinit { introOfferTokenManager.stopObservingAppLifecycle() + if hasRegisteredCallback { + customCallbackRegistry.unregister(paywallIdentifier: paywall.identifier) + } } private func configureUI() { diff --git a/Sources/SuperwallKit/Paywall/View Controller/Web View/LocalFileSchemeHandler.swift b/Sources/SuperwallKit/Paywall/View Controller/Web View/LocalFileSchemeHandler.swift index 6a179e4326..dfa78b5ef1 100644 --- a/Sources/SuperwallKit/Paywall/View Controller/Web View/LocalFileSchemeHandler.swift +++ b/Sources/SuperwallKit/Paywall/View Controller/Web View/LocalFileSchemeHandler.swift @@ -7,6 +7,9 @@ import Foundation import WebKit +#if canImport(UIKit) +import UIKit +#endif /// Handles custom URL scheme requests for serving local files to the paywall webview. /// @@ -27,6 +30,12 @@ final class LocalFileSchemeHandler: NSObject, WKURLSchemeHandler { /// The custom URL scheme for local files static let scheme = "swlocal" + #if canImport(UIKit) + /// Caches PNG-encoded bytes per `UIImage` so repeat webview requests skip re-encoding. + /// Shared across handler instances so reuse across paywalls also hits the cache. + private static let imageDataCache = NSCache() + #endif + /// Errors that can occur during file loading enum FileError: LocalizedError, Equatable { case invalidURL @@ -83,7 +92,8 @@ final class LocalFileSchemeHandler: NSObject, WKURLSchemeHandler { // MARK: - File Loading - /// Loads a file from `SuperwallOptions.localResources` based on the URL host (the localResourceId). + /// Loads a file from `SuperwallOptions.localResources` based on the URL host + /// (the localResourceId). /// - Parameter url: The swlocal:// URL where the host is the localResourceId /// - Returns: Tuple of file data and MIME type func loadFile(from url: URL) throws -> (Data, String) { @@ -91,16 +101,38 @@ final class LocalFileSchemeHandler: NSObject, WKURLSchemeHandler { throw FileError.invalidURL } - guard let localURL = Superwall.shared.options.localResources[host] else { + guard let resource = Superwall.shared.options.localResources[host] else { throw FileError.fileNotFound(host) } + return try load(resource: resource, key: host) + } + + /// Resolves an ``AssetResource`` to its data and MIME type. + private func load(resource: AssetResource, key: String) throws -> (Data, String) { + if let localURL = resource as? URL { + return try loadFile(at: localURL) + } + #if canImport(UIKit) + if let image = resource as? UIImage { + if let cached = Self.imageDataCache.object(forKey: image) { + return (cached as Data, "image/png") + } + guard let data = image.pngData() else { + throw FileError.unableToReadFile("\(key) (UIImage pngData nil)") + } + Self.imageDataCache.setObject(data as NSData, forKey: image) + return (data, "image/png") + } + #endif + throw FileError.fileNotFound(key) + } + + private func loadFile(at localURL: URL) throws -> (Data, String) { guard let data = try? Data(contentsOf: localURL) else { throw FileError.unableToReadFile(localURL.path) } - - let mimeType = self.mimeType(for: localURL.pathExtension) - return (data, mimeType) + return (data, mimeType(for: localURL.pathExtension)) } // MARK: - MIME Type Detection diff --git a/Sources/SuperwallKit/Superwall.swift b/Sources/SuperwallKit/Superwall.swift index b7899d89ba..e75668226d 100644 --- a/Sources/SuperwallKit/Superwall.swift +++ b/Sources/SuperwallKit/Superwall.swift @@ -420,6 +420,7 @@ public final class Superwall: NSObject, ObservableObject { completion: (() -> Void)? ) { let dependencyContainer = DependencyContainer( + apiKey: apiKey, purchaseController: purchaseController, options: options ) @@ -444,8 +445,6 @@ public final class Superwall: NSObject, ObservableObject { #endif } - dependencyContainer.storage.configure(apiKey: apiKey) - dependencyContainer.storage.recordAppInstall(trackPlacement: track) async let fetchConfig: () = await dependencyContainer.configManager.fetchConfiguration() diff --git a/SuperwallKit.podspec b/SuperwallKit.podspec index cd0272c647..8b1f2be8b1 100644 --- a/SuperwallKit.podspec +++ b/SuperwallKit.podspec @@ -1,7 +1,7 @@ Pod::Spec.new do |s| s.name = "SuperwallKit" - s.version = "4.15.0" + s.version = "4.15.1" s.summary = "Superwall: In-App Paywalls Made Easy" s.description = "Paywall infrastructure for mobile apps :) we make things like editing your paywall and running price tests as easy as clicking a few buttons. superwall.com" diff --git a/SuperwallKit.xcodeproj/project.pbxproj b/SuperwallKit.xcodeproj/project.pbxproj index a5819ce27e..6501ada07d 100644 --- a/SuperwallKit.xcodeproj/project.pbxproj +++ b/SuperwallKit.xcodeproj/project.pbxproj @@ -156,6 +156,7 @@ 44E2AE9B0AED16C48027CD21 /* CustomCallback.swift in Sources */ = {isa = PBXBuildFile; fileRef = E439B70BB6190AFF6DDB81F2 /* CustomCallback.swift */; }; 454421E34ED200400A001AE1 /* PaywallManagerLogic.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8012E350CCE22B0D892E0F96 /* PaywallManagerLogic.swift */; }; 480C37A4D7A8AB5EE0760BF1 /* PaywallLogic.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC6C4D551369C55D8AFB7F96 /* PaywallLogic.swift */; }; + 49A7156A67C8BAB23F97EC39 /* EmailTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B6BF63B250AE0D83DECFCD0 /* EmailTests.swift */; }; 4A4E5413A8753AFB624D325D /* PermissionTypeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFD2580D6C95C96CC3051BCB /* PermissionTypeTests.swift */; }; 4A4E788046CD308F465B37BF /* ProductsFetcherSK2.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57AD390BC73341A49301B4AA /* ProductsFetcherSK2.swift */; }; 4AA4E2CE223DC7CF1678E83C /* TrackTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65E23B703C00044332FDEBE8 /* TrackTests.swift */; }; @@ -370,6 +371,7 @@ B15607185B9E4229C6C4F240 /* SK2StoreTransaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3B96E2A1A289D96267EC0BC /* SK2StoreTransaction.swift */; }; B294572426111EC04F225289 /* MockExternalPurchaseControllerFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB9C9132109020FA03D1D5C7 /* MockExternalPurchaseControllerFactory.swift */; }; B2AB1E9283FDE2D544C8BCA8 /* MockReceiptData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 39B81D88316F06C0C2757F10 /* MockReceiptData.swift */; }; + B2AC4436371BC96FAA4FB5B3 /* CustomCallbackRegistryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CFC75AD1252D05D2033D7B0 /* CustomCallbackRegistryTests.swift */; }; B2B5684F46FB49AB9E3C1BE0 /* Cache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03E47DA89C9F4FBD7FA038F5 /* Cache.swift */; }; B3E6E82C0240EE6048360C9B /* RawWebMessageHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C39015ED9E8F5D9F0F78F1E /* RawWebMessageHandler.swift */; }; B456ED8E41AD35A0EF5C295F /* ProductVariable.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC8730F0D05BFDFA12AA6309 /* ProductVariable.swift */; }; @@ -382,6 +384,7 @@ B7750B3709CE8073697C9A9C /* OpacityAnimation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 532AB25EB4DA9BAA5E3FA530 /* OpacityAnimation.swift */; }; B8483A96A7AE8440AF1E0C3B /* SKProductSubscriptionPeriodMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5BF66C3986E287B7B2F5E27 /* SKProductSubscriptionPeriodMock.swift */; }; B84CA6014D8D6EF201DB3935 /* LocalizationGrouping.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABC1253C6D5DD8D967BE05D1 /* LocalizationGrouping.swift */; }; + B89435087910E6B501471622 /* Email.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1BDB77756A3775FF4ED31C48 /* Email.swift */; }; B91D4755E1FDCBBC2D3CD8C3 /* InternalPresentation.swift in Sources */ = {isa = PBXBuildFile; fileRef = B36299FDDEC7022F0F45A801 /* InternalPresentation.swift */; }; BA1416132CD360BCBA93D698 /* WebArchiveFileSytemManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCC728E79E36A4CDD87F3078 /* WebArchiveFileSytemManager.swift */; }; BA957415E2E1A38A25550B99 /* MockIntroductoryPeriod.swift in Sources */ = {isa = PBXBuildFile; fileRef = 296A4AFE25C5E55DC5DD207D /* MockIntroductoryPeriod.swift */; }; @@ -442,6 +445,7 @@ CE43399599386456DCCD1FDC /* FakeLocationAuthorizationStatusTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3529B422EB2D09B69265B591 /* FakeLocationAuthorizationStatusTests.swift */; }; CE5307A037FCFD8CB8380EB5 /* GetPaywallManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3781CF21200CD2333F6779A /* GetPaywallManager.swift */; }; CE821667CB6676EA02510FE9 /* TrackingManagerProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = A78C5C57C3C92444EBAC2E38 /* TrackingManagerProxy.swift */; }; + CE85C4CC2D3E98738F2C37E7 /* AssetResource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23307BBFD80385233DDD4C43 /* AssetResource.swift */; }; CEF9BBFFE0D64A011A59FB4C /* TestModeManagerFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = B17DB6AB272712E9350966E4 /* TestModeManagerFactory.swift */; }; CF2064883604B915C8768FC5 /* ASIdManagerProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF989B3D90DC3D88FACC4D45 /* ASIdManagerProxy.swift */; }; CF3683E2AD703237EC0CE22E /* PaywallProducts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C95DABA23C6CBEF0AAA63C0 /* PaywallProducts.swift */; }; @@ -595,6 +599,7 @@ 0A716D8F8AA3CD7BBED04F4F /* TriggerAudienceOccurrence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TriggerAudienceOccurrence.swift; sourceTree = ""; }; 0A9F09187825FB944A3BD8A9 /* DeepLinkRouterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeepLinkRouterTests.swift; sourceTree = ""; }; 0B31ACE25727649F21DEEBAF /* AttributionPoster.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttributionPoster.swift; sourceTree = ""; }; + 0B6BF63B250AE0D83DECFCD0 /* EmailTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmailTests.swift; sourceTree = ""; }; 0C0D4F9888B5827992153F5F /* TestModeModalViewController+TableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TestModeModalViewController+TableView.swift"; sourceTree = ""; }; 0C39015ED9E8F5D9F0F78F1E /* RawWebMessageHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RawWebMessageHandler.swift; sourceTree = ""; }; 0C8FD734F57A50B6EAFBF833 /* TestModeModalViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestModeModalViewController.swift; sourceTree = ""; }; @@ -630,6 +635,7 @@ 1AD42859B8BFEC078665FA1E /* StripeStoreProductDiscount.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StripeStoreProductDiscount.swift; sourceTree = ""; }; 1B1A6ADFFB9FA982BF69C134 /* MockSKPaymentTransaction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockSKPaymentTransaction.swift; sourceTree = ""; }; 1B78FB9232236AF44369EA92 /* AppSessionManagerMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSessionManagerMock.swift; sourceTree = ""; }; + 1BDB77756A3775FF4ED31C48 /* Email.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Email.swift; sourceTree = ""; }; 1C16CBCBF2093DD9C5F3E105 /* Storage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Storage.swift; sourceTree = ""; }; 1CC92F1146FA9FA76AF25227 /* TrackingAuthorizationStatusConversionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackingAuthorizationStatusConversionTests.swift; sourceTree = ""; }; 1D275ED98D2EE298F06708AF /* UIWindow+SwizzleSendEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIWindow+SwizzleSendEvent.swift"; sourceTree = ""; }; @@ -646,6 +652,7 @@ 22439CFFFC5166F34D0DA524 /* WebViewURLConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewURLConfig.swift; sourceTree = ""; }; 22919EFD263425D38E7D9D38 /* WebEntitlementRedeemer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebEntitlementRedeemer.swift; sourceTree = ""; }; 22D96B4C9B546F7B0EC73397 /* PaywallViewControllerWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallViewControllerWrapper.swift; sourceTree = ""; }; + 23307BBFD80385233DDD4C43 /* AssetResource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetResource.swift; sourceTree = ""; }; 236900A8A8F95CE92E612458 /* IdentityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentityManager.swift; sourceTree = ""; }; 2378D0EF4F79DDF0BC45B389 /* FakeLocationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FakeLocationManager.swift; sourceTree = ""; }; 23886A83274F67B1DCB8573A /* SWWebViewLoadingHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SWWebViewLoadingHandlerTests.swift; sourceTree = ""; }; @@ -874,6 +881,7 @@ 8BAEECE2DBFEB8817E6C36DA /* PaywallViewControllerCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallViewControllerCache.swift; sourceTree = ""; }; 8BDA9D233EACAB5090B0657D /* StorePresentationObjectsOperatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorePresentationObjectsOperatorTests.swift; sourceTree = ""; }; 8CA2E0A3532CE2F0AFD6F20B /* nb */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nb; path = nb.lproj/Localizable.strings; sourceTree = ""; }; + 8CFC75AD1252D05D2033D7B0 /* CustomCallbackRegistryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomCallbackRegistryTests.swift; sourceTree = ""; }; 8D45DC0981EE9BBE3BF56C48 /* PurchaseController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PurchaseController.swift; sourceTree = ""; }; 8D5F8BE7E93645C0FCA49E4A /* PopupTransition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PopupTransition.swift; sourceTree = ""; }; 8D9545633A97A2E63FEDF78A /* SWWebViewLoadingHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SWWebViewLoadingHandler.swift; sourceTree = ""; }; @@ -1651,6 +1659,7 @@ 373AFF230833A951B6E5DF36 /* Identity */ = { isa = PBXGroup; children = ( + 0B6BF63B250AE0D83DECFCD0 /* EmailTests.swift */, 40AE19B5A9B237A2552D5F36 /* IdentityLogicTests.swift */, 2714D9FD9F55611B4C5E4E7D /* IdentityManagerMock.swift */, EF6B2C623B994FF663F75719 /* IdentityManagerTests.swift */, @@ -2018,6 +2027,7 @@ 66F9C998E9BBCFFCF80386FE /* Identity */ = { isa = PBXGroup; children = ( + 1BDB77756A3775FF4ED31C48 /* Email.swift */, 3C1EB433A4E4342E03BB7744 /* IdentityInfo.swift */, AB3F04AC933701EE33F5F325 /* IdentityLogic.swift */, 236900A8A8F95CE92E612458 /* IdentityManager.swift */, @@ -2076,6 +2086,7 @@ 75743C9AAFCAB9D91984F19C /* Presentation */ = { isa = PBXGroup; children = ( + 8CFC75AD1252D05D2033D7B0 /* CustomCallbackRegistryTests.swift */, A3F306D67A9F3A43D082DD83 /* PresentationIdTests.swift */, 43F99E26CAFD72F228189A4D /* Audience Logic */, B2C3E282003472D3326477C4 /* Internal Presentation */, @@ -2903,6 +2914,7 @@ E7D208EAB24A633EB74B1E31 /* Options */ = { isa = PBXGroup; children = ( + 23307BBFD80385233DDD4C43 /* AssetResource.swift */, B1A64CCBCB23CC1715DF79AC /* PaywallOptions.swift */, CFDA311A030FDFC45AEE248A /* SuperwallOptions.swift */, ); @@ -3189,6 +3201,7 @@ A1621A749D8F05959A486ACE /* CoreDataManagerMock.swift in Sources */, C7AB21123540550E513AD28A /* CoreDataManagerTests.swift in Sources */, ABC17AE96AD396607E3CAB17 /* CoreDataStackMock.swift in Sources */, + B2AC4436371BC96FAA4FB5B3 /* CustomCallbackRegistryTests.swift in Sources */, 2517FC60F3A7288C5FE34A73 /* CustomProductTests.swift in Sources */, 85728EABBC5C73193AC5F876 /* CustomURLSessionMock.swift in Sources */, 37FDB46DD55E649FA10D753C /* CustomerInfoDecodingTests.swift in Sources */, @@ -3197,6 +3210,7 @@ 01BE837B492223B76A95CB5D /* DeepLinkRouterTests.swift in Sources */, 0CA13E721ADB243882536D4A /* DeviceHelperMock.swift in Sources */, 9DBDDD10A1EFC7CD3575D9E5 /* DeviceHelperTests.swift in Sources */, + 49A7156A67C8BAB23F97EC39 /* EmailTests.swift in Sources */, A03AC977AD8110290DABECBD /* EntitlementPriorityTests.swift in Sources */, 6BA614F410A95F36CBA42F93 /* EntitlementProcessorTests.swift in Sources */, 1225B991B40D16B7FB4EF1A5 /* EvaluateRulesOperatorTests.swift in Sources */, @@ -3319,6 +3333,7 @@ 9DC74748B0DC9DA519E70FE6 /* Array+Capability.swift in Sources */, F8E799A3A83A2758D6EAA385 /* Array+Guarded.swift in Sources */, B0B0AD9409CEFE7CA8225146 /* Array+SafeRemove.swift in Sources */, + CE85C4CC2D3E98738F2C37E7 /* AssetResource.swift in Sources */, 2428529A6B2B6E873DEC22E8 /* Assignment.swift in Sources */, 75083E470EB6E25E01F4F28B /* AsyncSequence+Extract.swift in Sources */, 69D24C6E0411E512FECF7258 /* Attribution.swift in Sources */, @@ -3377,6 +3392,7 @@ 507E017DBEC2663F1B4727E0 /* Dictionary+Merging.swift in Sources */, DB6FF170AE90FF8623A31E14 /* DispatchQueueBacked.swift in Sources */, 61AE58F17230AE1578B9FB19 /* Documentation.docc in Sources */, + B89435087910E6B501471622 /* Email.swift in Sources */, 9EAE577E60052F5E1C7B9657 /* EmptyResponse.swift in Sources */, CB1E11FB74879A29DD1C9EB1 /* Encodable+Dictionary.swift in Sources */, 11477D1EB60D1FDA32F5099A /* Endpoint.swift in Sources */, diff --git a/Tests/SuperwallKitTests/Analytics/Internal Tracking/TrackTests.swift b/Tests/SuperwallKitTests/Analytics/Internal Tracking/TrackTests.swift index 51decc4b4c..4e9aa19859 100644 --- a/Tests/SuperwallKitTests/Analytics/Internal Tracking/TrackTests.swift +++ b/Tests/SuperwallKitTests/Analytics/Internal Tracking/TrackTests.swift @@ -1695,6 +1695,79 @@ struct TrackingTests { result.parameters.audienceFilterParams["presented_by_event_name"] as? String == paywallInfo.presentedByPlacementWithName) } + @Test func transaction_abandon() async { + let paywallInfo: PaywallInfo = .stub() + let productId = "abc" + let product = StoreProduct( + sk1Product: MockSkProduct( + subscriptionPeriod: SKProductSubscriptionPeriodMock(numberOfUnits: 1, unit: .month), + productIdentifier: productId + ), + entitlements: [.stub()] + ) + let dependencyContainer = DependencyContainer() + let skTransaction = MockSKPaymentTransaction(state: .purchased) + let transaction = await dependencyContainer.makeStoreTransaction(from: skTransaction) + let result = await Superwall.shared.track( + InternalSuperwallEvent.Transaction( + state: .abandon(product), + paywallInfo: paywallInfo, + product: product, + transaction: transaction, + source: .external, + isObserved: false, + storeKitVersion: .storeKit1 + ) + ) + + #expect(result.parameters.audienceFilterParams["$event_name"] as! String == "transaction_abandon") + #expect(result.parameters.audienceFilterParams["$product_period"] != nil) + #expect(result.parameters.audienceFilterParams["$product_period_months"] != nil) + + #expect( + result.parameters.audienceFilterParams["event_name"] as! String == "transaction_abandon") + #expect( + result.parameters.audienceFilterParams["abandoned_product_id"] as! String == productId) + #expect(result.parameters.audienceFilterParams["abandoned_product_identifier"] == nil) + #expect(result.parameters.audienceFilterParams["abandoned_product_period"] as? String == "month") + #expect(result.parameters.audienceFilterParams["abandoned_product_period_months"] as? String == "1") + #expect(result.parameters.audienceFilterParams["abandoned_product_period_years"] as? String == "0") + #expect((result.parameters.audienceFilterParams["abandoned_product_localized_period"] as? String)?.isEmpty == false) + } + + @Test func transaction_abandon_consumable() async { + let paywallInfo: PaywallInfo = .stub() + let productId = "consumable.coins" + let product = StoreProduct( + sk1Product: MockSkProduct( + productIdentifier: productId, + price: NSDecimalNumber(string: "4.99") + ), + entitlements: [.stub()] + ) + let dependencyContainer = DependencyContainer() + let skTransaction = MockSKPaymentTransaction(state: .purchased) + let transaction = await dependencyContainer.makeStoreTransaction(from: skTransaction) + let result = await Superwall.shared.track( + InternalSuperwallEvent.Transaction( + state: .abandon(product), + paywallInfo: paywallInfo, + product: product, + transaction: transaction, + source: .external, + isObserved: false, + storeKitVersion: .storeKit1 + ) + ) + + #expect(result.parameters.audienceFilterParams["abandoned_product_id"] as? String == productId) + #expect(result.parameters.audienceFilterParams["abandoned_product_identifier"] == nil) + #expect(result.parameters.audienceFilterParams["abandoned_product_raw_price"] as? String == "4.99") + #expect((result.parameters.audienceFilterParams["abandoned_product_price"] as? String)?.isEmpty == false) + #expect((result.parameters.audienceFilterParams["abandoned_product_currency_code"] as? String)?.isEmpty == false) + #expect(result.parameters.audienceFilterParams["abandoned_product_period"] as? String == "") + } + @Test func transaction_fail() async { let paywallInfo: PaywallInfo = .stub() let productId = "abc" diff --git a/Tests/SuperwallKitTests/Analytics/Internal Tracking/TrackingLogicTests.swift b/Tests/SuperwallKitTests/Analytics/Internal Tracking/TrackingLogicTests.swift index cc5c470d27..c06021c6ae 100644 --- a/Tests/SuperwallKitTests/Analytics/Internal Tracking/TrackingLogicTests.swift +++ b/Tests/SuperwallKitTests/Analytics/Internal Tracking/TrackingLogicTests.swift @@ -320,7 +320,8 @@ struct TrackingLogicTests { webView: webView, webEntitlementRedeemer: dependencyContainer.webEntitlementRedeemer, cache: nil, - paywallArchiveManager: nil + paywallArchiveManager: nil, + customCallbackRegistry: dependencyContainer.customCallbackRegistry ) let outcome = await TrackingLogic.canTriggerPaywall( diff --git a/Tests/SuperwallKitTests/Identity/EmailTests.swift b/Tests/SuperwallKitTests/Identity/EmailTests.swift new file mode 100644 index 0000000000..5faee6b89b --- /dev/null +++ b/Tests/SuperwallKitTests/Identity/EmailTests.swift @@ -0,0 +1,90 @@ +// +// EmailTests.swift +// SuperwallKit +// + +import Testing +@testable import SuperwallKit + +@Suite("Email") +struct EmailTests { + + // MARK: - Valid emails + + @Test("accepts a simple email address") + func simpleEmail() { + #expect(Email("user@example.com") != nil) + } + + @Test("accepts email with dots in local part") + func dottedLocalPart() { + #expect(Email("first.last@domain.co") != nil) + } + + @Test("accepts email with plus tag") + func plusTag() { + #expect(Email("user+tag@example.com") != nil) + } + + @Test("accepts email with subdomain") + func subdomain() { + #expect(Email("user@mail.example.co.uk") != nil) + } + + @Test("accepts email with numbers") + func numbers() { + #expect(Email("user123@domain456.com") != nil) + } + + @Test("accepts email with hyphen in domain") + func hyphenatedDomain() { + #expect(Email("user@my-domain.com") != nil) + } + + @Test("accepts email with underscore and percent") + func underscoreAndPercent() { + #expect(Email("user_%name@domain.org") != nil) + } + + @Test("preserves the raw value on success") + func preservesRawValue() { + let address = "hello@world.com" + let email = Email(address) + #expect(email?.rawValue == address) + } + + // MARK: - Invalid emails + + @Test( + "rejects strings that are not valid email addresses", + arguments: [ + "none", + "", + "userexample.com", + "user@", + "@example.com", + "user@domain", + "user@domain.a", + "user @example.com", + "not an email", + "null", + "N/A", + "user@example.com\n", + ] + ) + func rejectsInvalidValue(value: String) { + #expect(Email(value) == nil) + } + + // MARK: - Equatable + + @Test("two emails with the same address are equal") + func equalEmails() { + #expect(Email("a@b.com") == Email("a@b.com")) + } + + @Test("two emails with different addresses are not equal") + func differentEmails() { + #expect(Email("a@b.com") != Email("x@y.com")) + } +} diff --git a/Tests/SuperwallKitTests/Paywall/Presentation/CustomCallbackRegistryTests.swift b/Tests/SuperwallKitTests/Paywall/Presentation/CustomCallbackRegistryTests.swift new file mode 100644 index 0000000000..3be0a2de97 --- /dev/null +++ b/Tests/SuperwallKitTests/Paywall/Presentation/CustomCallbackRegistryTests.swift @@ -0,0 +1,167 @@ +// +// CustomCallbackRegistryTests.swift +// +// +// Created by Yusuf Tör on 22/04/2026. +// + +import Testing +import Foundation +@testable import SuperwallKit + +@Suite +@MainActor +struct CustomCallbackGetPaywallTests { + private func makePaywallVc( + paywall: Paywall, + delegate: PaywallViewControllerDelegateAdapter?, + in dependencyContainer: DependencyContainer + ) -> PaywallViewControllerMock { + let messageHandler = PaywallMessageHandler( + receiptManager: dependencyContainer.receiptManager, + factory: dependencyContainer, + permissionHandler: FakePermissionHandler(), + customCallbackRegistry: dependencyContainer.customCallbackRegistry + ) + let webView = SWWebView( + isMac: false, + messageHandler: messageHandler, + isOnDeviceCacheEnabled: true, + factory: dependencyContainer + ) + return PaywallViewControllerMock( + paywall: paywall, + delegate: delegate, + deviceHelper: dependencyContainer.deviceHelper, + factory: dependencyContainer, + storage: dependencyContainer.storage, + network: dependencyContainer.network, + webView: webView, + webEntitlementRedeemer: dependencyContainer.webEntitlementRedeemer, + cache: nil, + paywallArchiveManager: nil, + customCallbackRegistry: dependencyContainer.customCallbackRegistry + ) + } + + @Test + func customCallbackHandlerIsRegisteredWhenAdapterHasHandler() async { + let dependencyContainer = DependencyContainer() + let paywall = Paywall.stub() + .setting(\.identifier, to: "paywall_register_test") + + let adapter = PaywallViewControllerDelegateAdapter( + swiftDelegate: nil, + objcDelegate: nil, + onCustomCallback: { callback in + return .success(data: ["echo": callback.name]) + } + ) + + let paywallVc = makePaywallVc(paywall: paywall, delegate: adapter, in: dependencyContainer) + + let registered = dependencyContainer.customCallbackRegistry.getHandler( + paywallIdentifier: paywall.identifier + ) + #expect(registered != nil) + + let result = await registered?(CustomCallback(name: "ping", variables: nil)) + #expect(result?.status == .success) + #expect(result?.data?["echo"] as? String == "ping") + + _ = paywallVc + } + + @Test + func noHandlerRegisteredWhenAdapterHasNone() async { + let dependencyContainer = DependencyContainer() + let paywall = Paywall.stub() + .setting(\.identifier, to: "paywall_no_handler_test") + + let adapter = PaywallViewControllerDelegateAdapter( + swiftDelegate: nil, + objcDelegate: nil, + onCustomCallback: nil + ) + + let paywallVc = makePaywallVc(paywall: paywall, delegate: adapter, in: dependencyContainer) + + let registered = dependencyContainer.customCallbackRegistry.getHandler( + paywallIdentifier: paywall.identifier + ) + #expect(registered == nil) + + _ = paywallVc + } + + @Test + func reassigningDelegateUpdatesRegistration() async { + let dependencyContainer = DependencyContainer() + let paywall = Paywall.stub() + .setting(\.identifier, to: "paywall_reassign_test") + + let firstAdapter = PaywallViewControllerDelegateAdapter( + swiftDelegate: nil, + objcDelegate: nil, + onCustomCallback: { _ in .success(data: ["origin": "first"]) } + ) + + let paywallVc = makePaywallVc( + paywall: paywall, + delegate: firstAdapter, + in: dependencyContainer + ) + + let firstResult = await dependencyContainer.customCallbackRegistry.getHandler( + paywallIdentifier: paywall.identifier + )?(CustomCallback(name: "anything", variables: nil)) + #expect(firstResult?.data?["origin"] as? String == "first") + + let secondAdapter = PaywallViewControllerDelegateAdapter( + swiftDelegate: nil, + objcDelegate: nil, + onCustomCallback: { _ in .success(data: ["origin": "second"]) } + ) + paywallVc.delegate = secondAdapter + + let secondResult = await dependencyContainer.customCallbackRegistry.getHandler( + paywallIdentifier: paywall.identifier + )?(CustomCallback(name: "anything", variables: nil)) + #expect(secondResult?.data?["origin"] as? String == "second") + + paywallVc.delegate = nil + + let cleared = dependencyContainer.customCallbackRegistry.getHandler( + paywallIdentifier: paywall.identifier + ) + #expect(cleared == nil) + } + + @Test + func handlerIsUnregisteredWhenViewControllerDeinits() async { + let dependencyContainer = DependencyContainer() + let paywall = Paywall.stub() + .setting(\.identifier, to: "paywall_deinit_test") + + weak var weakVc: PaywallViewControllerMock? + autoreleasepool { + let adapter = PaywallViewControllerDelegateAdapter( + swiftDelegate: nil, + objcDelegate: nil, + onCustomCallback: { _ in .success(data: nil) } + ) + let pvc = makePaywallVc(paywall: paywall, delegate: adapter, in: dependencyContainer) + weakVc = pvc + #expect(dependencyContainer.customCallbackRegistry.getHandler( + paywallIdentifier: paywall.identifier + ) != nil) + _ = pvc + } + + #expect(weakVc == nil) + let registered = dependencyContainer.customCallbackRegistry.getHandler( + paywallIdentifier: paywall.identifier + ) + #expect(registered == nil) + } +} diff --git a/Tests/SuperwallKitTests/Paywall/Presentation/Internal Presentation/Operators/PresentPaywallOperatorTests.swift b/Tests/SuperwallKitTests/Paywall/Presentation/Internal Presentation/Operators/PresentPaywallOperatorTests.swift index d4de259c75..5061dee3f8 100644 --- a/Tests/SuperwallKitTests/Paywall/Presentation/Internal Presentation/Operators/PresentPaywallOperatorTests.swift +++ b/Tests/SuperwallKitTests/Paywall/Presentation/Internal Presentation/Operators/PresentPaywallOperatorTests.swift @@ -55,7 +55,8 @@ final class PresentPaywallOperatorTests { webView: webView, webEntitlementRedeemer: dependencyContainer.webEntitlementRedeemer, cache: nil, - paywallArchiveManager: nil + paywallArchiveManager: nil, + customCallbackRegistry: dependencyContainer.customCallbackRegistry ) webView.delegate = paywallVc @@ -124,7 +125,8 @@ final class PresentPaywallOperatorTests { webView: webView, webEntitlementRedeemer: dependencyContainer.webEntitlementRedeemer, cache: nil, - paywallArchiveManager: nil + paywallArchiveManager: nil, + customCallbackRegistry: dependencyContainer.customCallbackRegistry ) paywallVc.shouldPresent = false webView.delegate = paywallVc diff --git a/Tests/SuperwallKitTests/Paywall/View Controller/SurveyManagerTests.swift b/Tests/SuperwallKitTests/Paywall/View Controller/SurveyManagerTests.swift index 0842631700..ed844e5740 100644 --- a/Tests/SuperwallKitTests/Paywall/View Controller/SurveyManagerTests.swift +++ b/Tests/SuperwallKitTests/Paywall/View Controller/SurveyManagerTests.swift @@ -40,7 +40,8 @@ struct SurveyManagerTests { webView: webView, webEntitlementRedeemer: dependencyContainer.webEntitlementRedeemer, cache: nil, - paywallArchiveManager: nil + paywallArchiveManager: nil, + customCallbackRegistry: dependencyContainer.customCallbackRegistry ) await confirmation { completed in @@ -87,7 +88,8 @@ struct SurveyManagerTests { webView: webView, webEntitlementRedeemer: dependencyContainer.webEntitlementRedeemer, cache: nil, - paywallArchiveManager: nil + paywallArchiveManager: nil, + customCallbackRegistry: dependencyContainer.customCallbackRegistry ) await confirmation { completed in @@ -134,7 +136,8 @@ struct SurveyManagerTests { webView: webView, webEntitlementRedeemer: dependencyContainer.webEntitlementRedeemer, cache: nil, - paywallArchiveManager: nil + paywallArchiveManager: nil, + customCallbackRegistry: dependencyContainer.customCallbackRegistry ) await confirmation(expectedCount: 0) { completed in @@ -182,7 +185,8 @@ struct SurveyManagerTests { webView: webView, webEntitlementRedeemer: dependencyContainer.webEntitlementRedeemer, cache: nil, - paywallArchiveManager: nil + paywallArchiveManager: nil, + customCallbackRegistry: dependencyContainer.customCallbackRegistry ) await confirmation { completed in @@ -229,7 +233,8 @@ struct SurveyManagerTests { webView: webView, webEntitlementRedeemer: dependencyContainer.webEntitlementRedeemer, cache: nil, - paywallArchiveManager: nil + paywallArchiveManager: nil, + customCallbackRegistry: dependencyContainer.customCallbackRegistry ) await confirmation { completed in @@ -276,7 +281,8 @@ struct SurveyManagerTests { webView: webView, webEntitlementRedeemer: dependencyContainer.webEntitlementRedeemer, cache: nil, - paywallArchiveManager: nil + paywallArchiveManager: nil, + customCallbackRegistry: dependencyContainer.customCallbackRegistry ) await confirmation { completed in @@ -329,7 +335,8 @@ struct SurveyManagerTests { webView: webView, webEntitlementRedeemer: dependencyContainer.webEntitlementRedeemer, cache: nil, - paywallArchiveManager: nil + paywallArchiveManager: nil, + customCallbackRegistry: dependencyContainer.customCallbackRegistry ) await confirmation { completed in @@ -385,7 +392,8 @@ struct SurveyManagerTests { webView: webView, webEntitlementRedeemer: dependencyContainer.webEntitlementRedeemer, cache: nil, - paywallArchiveManager: nil + paywallArchiveManager: nil, + customCallbackRegistry: dependencyContainer.customCallbackRegistry ) await confirmation { completed in @@ -437,7 +445,8 @@ struct SurveyManagerTests { webView: webView, webEntitlementRedeemer: dependencyContainer.webEntitlementRedeemer, cache: nil, - paywallArchiveManager: nil + paywallArchiveManager: nil, + customCallbackRegistry: dependencyContainer.customCallbackRegistry ) await confirmation(expectedCount: 0) { completed in @@ -495,7 +504,8 @@ struct SurveyManagerTests { webView: webView, webEntitlementRedeemer: dependencyContainer.webEntitlementRedeemer, cache: nil, - paywallArchiveManager: nil + paywallArchiveManager: nil, + customCallbackRegistry: dependencyContainer.customCallbackRegistry ) await confirmation(expectedCount: 0) { completed in diff --git a/Tests/SuperwallKitTests/Paywall/View Controller/Web View/LocalFileSchemeHandlerTests.swift b/Tests/SuperwallKitTests/Paywall/View Controller/Web View/LocalFileSchemeHandlerTests.swift index 19470ec00a..9125c535b3 100644 --- a/Tests/SuperwallKitTests/Paywall/View Controller/Web View/LocalFileSchemeHandlerTests.swift +++ b/Tests/SuperwallKitTests/Paywall/View Controller/Web View/LocalFileSchemeHandlerTests.swift @@ -9,6 +9,7 @@ import Testing @testable import SuperwallKit import Foundation +import UIKit @Suite(.serialized) struct LocalFileSchemeHandlerTests { @@ -98,6 +99,45 @@ struct LocalFileSchemeHandlerTests { Superwall.shared.options.localResources = [:] } + // MARK: - AssetResource Tests + + @Test("URL conforms to AssetResource and registers via the same dictionary") + func loadFileURLConformance() throws { + let handler = LocalFileSchemeHandler() + let tempDir = FileManager.default.temporaryDirectory + let tempFile = tempDir.appendingPathComponent("asset-resource-url.png") + try Data("png-bytes".utf8).write(to: tempFile) + defer { try? FileManager.default.removeItem(at: tempFile) } + + Superwall.shared.options.localResources = ["hero-image": tempFile] + let url = URL(string: "swlocal://hero-image")! + + let (data, mimeType) = try handler.loadFile(from: url) + #expect(data == Data("png-bytes".utf8)) + #expect(mimeType == "image/png") + + Superwall.shared.options.localResources = [:] + } + + @Test("UIImage conforms to AssetResource and is served as image/png") + func loadFileUIImageConformance() throws { + let handler = LocalFileSchemeHandler() + let renderer = UIGraphicsImageRenderer(size: CGSize(width: 4, height: 4)) + let image = renderer.image { ctx in + UIColor.red.setFill() + ctx.fill(CGRect(x: 0, y: 0, width: 4, height: 4)) + } + + Superwall.shared.options.localResources = ["logo": image] + let url = URL(string: "swlocal://logo")! + + let (data, mimeType) = try handler.loadFile(from: url) + #expect(mimeType == "image/png") + #expect(data == image.pngData()) + + Superwall.shared.options.localResources = [:] + } + @Test("loadFile detects correct mime types from file extension") func loadFileMimeTypes() throws { let handler = LocalFileSchemeHandler() diff --git a/Tests/SuperwallKitTests/Storage/StorageTests.swift b/Tests/SuperwallKitTests/Storage/StorageTests.swift index 868214b4d6..f7fd4516e5 100644 --- a/Tests/SuperwallKitTests/Storage/StorageTests.swift +++ b/Tests/SuperwallKitTests/Storage/StorageTests.swift @@ -6,11 +6,19 @@ // // swiftlint:disable all -import XCTest import Testing @testable import SuperwallKit -class StorageTests: XCTestCase { +@Suite +struct StorageTests { + @Test + func test_dependencyContainerInit_configuresApiKeySynchronously() { + let dependencyContainer = DependencyContainer(apiKey: "pk_test_123") + + #expect(dependencyContainer.storage.apiKey == "pk_test_123") + } + + @Test func test_overwriteAssignments() { let dependencyContainer = DependencyContainer() let storage = Storage(factory: dependencyContainer) @@ -29,12 +37,12 @@ class StorageTests: XCTestCase { storage.overwriteAssignments(assignments) let retrievedAssignments = storage.getAssignments() - XCTAssertEqual(retrievedAssignments.first, assignments.first) + #expect(retrievedAssignments.first == assignments.first) storage.reset() let retrievedAssignments2 = storage.getAssignments() - XCTAssertTrue(retrievedAssignments2.isEmpty) + #expect(retrievedAssignments2.isEmpty) } } diff --git a/Tests/SuperwallKitTests/StoreKit/Mocks/SKProductSubscriptionPeriodMock.swift b/Tests/SuperwallKitTests/StoreKit/Mocks/SKProductSubscriptionPeriodMock.swift index dc6bcd25ff..767e56a370 100644 --- a/Tests/SuperwallKitTests/StoreKit/Mocks/SKProductSubscriptionPeriodMock.swift +++ b/Tests/SuperwallKitTests/StoreKit/Mocks/SKProductSubscriptionPeriodMock.swift @@ -9,7 +9,22 @@ import Foundation import StoreKit final class SKProductSubscriptionPeriodMock: SKProductSubscriptionPeriod { + private let internalNumberOfUnits: Int + private let internalUnit: SKProduct.PeriodUnit + override var numberOfUnits: Int { - return 1 + return internalNumberOfUnits + } + + override var unit: SKProduct.PeriodUnit { + return internalUnit + } + + init( + numberOfUnits: Int = 1, + unit: SKProduct.PeriodUnit = .month + ) { + self.internalNumberOfUnits = numberOfUnits + self.internalUnit = unit } } diff --git a/Tests/SuperwallKitTests/Web/WebEntitlementRedeemerTests.swift b/Tests/SuperwallKitTests/Web/WebEntitlementRedeemerTests.swift index 0a9ffcf43b..a2c6374299 100644 --- a/Tests/SuperwallKitTests/Web/WebEntitlementRedeemerTests.swift +++ b/Tests/SuperwallKitTests/Web/WebEntitlementRedeemerTests.swift @@ -315,7 +315,8 @@ struct WebEntitlementRedeemerTests { webView: webView, webEntitlementRedeemer: dependencyContainer.webEntitlementRedeemer, cache: cache, - paywallArchiveManager: nil + paywallArchiveManager: nil, + customCallbackRegistry: dependencyContainer.customCallbackRegistry ) cache.save(paywallVc, forKey: "key") cache.activePaywallVcKey = "key" @@ -430,7 +431,8 @@ struct WebEntitlementRedeemerTests { webView: webView, webEntitlementRedeemer: dependencyContainer.webEntitlementRedeemer, cache: cache, - paywallArchiveManager: nil + paywallArchiveManager: nil, + customCallbackRegistry: dependencyContainer.customCallbackRegistry ) cache.save(paywallVc, forKey: "key") cache.activePaywallVcKey = "key" @@ -949,7 +951,8 @@ struct WebEntitlementRedeemerTests { webView: webView, webEntitlementRedeemer: dependencyContainer.webEntitlementRedeemer, cache: cache, - paywallArchiveManager: nil + paywallArchiveManager: nil, + customCallbackRegistry: dependencyContainer.customCallbackRegistry ) cache.save(paywallVc, forKey: "key") cache.activePaywallVcKey = "key" @@ -1122,7 +1125,8 @@ struct WebEntitlementRedeemerTests { webView: webView, webEntitlementRedeemer: dependencyContainer.webEntitlementRedeemer, cache: cache, - paywallArchiveManager: nil + paywallArchiveManager: nil, + customCallbackRegistry: dependencyContainer.customCallbackRegistry ) cache.save(paywallVc, forKey: "key") cache.activePaywallVcKey = "key" @@ -1304,7 +1308,8 @@ struct WebEntitlementRedeemerTests { webView: webView, webEntitlementRedeemer: dependencyContainer.webEntitlementRedeemer, cache: cache, - paywallArchiveManager: nil + paywallArchiveManager: nil, + customCallbackRegistry: dependencyContainer.customCallbackRegistry ) cache.save(paywallVc, forKey: "key") cache.activePaywallVcKey = "key" @@ -1488,7 +1493,8 @@ struct WebEntitlementRedeemerTests { webView: webView, webEntitlementRedeemer: dependencyContainer.webEntitlementRedeemer, cache: cache, - paywallArchiveManager: nil + paywallArchiveManager: nil, + customCallbackRegistry: dependencyContainer.customCallbackRegistry ) cache.save(paywallVc, forKey: "key") cache.activePaywallVcKey = "key" @@ -1666,7 +1672,8 @@ struct WebEntitlementRedeemerTests { webView: webView, webEntitlementRedeemer: dependencyContainer.webEntitlementRedeemer, cache: cache, - paywallArchiveManager: nil + paywallArchiveManager: nil, + customCallbackRegistry: dependencyContainer.customCallbackRegistry ) cache.save(paywallVc, forKey: "key") cache.activePaywallVcKey = "key" @@ -1830,7 +1837,8 @@ struct WebEntitlementRedeemerTests { webView: webView, webEntitlementRedeemer: dependencyContainer.webEntitlementRedeemer, cache: cache, - paywallArchiveManager: nil + paywallArchiveManager: nil, + customCallbackRegistry: dependencyContainer.customCallbackRegistry ) cache.save(paywallVc, forKey: "key") cache.activePaywallVcKey = "key"