Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions ios/PurchasesHybridCommon/Podfile
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ target 'PurchasesHybridCommon' do
# Pods for testing
pod 'Quick', '5.0.1'
pod 'Nimble', '10.0.0'
# Required to @testable import PurchasesHybridCommonUI (which links RevenueCatUI).
pod 'RevenueCatUI', '5.81.1'
end

target 'PurchasesHybridCommonIntegrationTests' do
Expand Down
2 changes: 1 addition & 1 deletion ios/PurchasesHybridCommon/Podfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,6 @@ SPEC CHECKSUMS:
RevenueCat: 693b033863262f9776130cc6c1f9255a74ab23ff
RevenueCatUI: 2c7092fa0a485454d2a474e80f241e6db591dd36

PODFILE CHECKSUM: 045f73862bfad74a49a46f2a82d68d590eca3dcb
PODFILE CHECKSUM: 97226f8402bdfd52bbb573a6430115edc4477558

COCOAPODS: 1.17.0
Original file line number Diff line number Diff line change
Expand Up @@ -73,11 +73,13 @@
57FFDE46286B606A0089A57B /* XCTestCase+Expectations.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DD3D1842810A81F00A19EC6 /* XCTestCase+Expectations.swift */; };
7720A92A2D520021009EA43C /* IntroEligibility+HybridExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7720A9292D520021009EA43C /* IntroEligibility+HybridExtensions.swift */; };
77D5E72A2C233E2C002ECC77 /* Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77D5E7292C233E2C002ECC77 /* Constants.swift */; };
8D7E9A16717774CB710220ED /* PaywallPresentationStyleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE5DB0D9EDF7E18D1D358ABE /* PaywallPresentationStyleTests.swift */; };
A1B2C3D42026021600AABBCC /* RCHybridPurchaseLogicBridgeAPITest.m in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D32026021600AABBCC /* RCHybridPurchaseLogicBridgeAPITest.m */; };
A1B2C3D62026021600AABBCC /* HybridPurchaseLogicBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D52026021600AABBCC /* HybridPurchaseLogicBridge.swift */; };
AE0B58267EE48614BBF43A9E /* Pods_PurchasesHybridCommonUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D02631302E453BBC6C5F5471 /* Pods_PurchasesHybridCommonUI.framework */; };
B1C9A7EEA6961CC6DE22BCAD /* Pods_PurchasesHybridCommon_PurchasesHybridCommonTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E05FA8EACFF88F020CF8B9BB /* Pods_PurchasesHybridCommon_PurchasesHybridCommonTests.framework */; };
B3105D7A287E3CC200CA935B /* MockSandboxEnvironmentDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3105D79287E3CC200CA935B /* MockSandboxEnvironmentDetector.swift */; };
E154E4AD206A01BCC76C3976 /* PurchasesHybridCommonUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4F7974672B5EDDEF003085E7 /* PurchasesHybridCommonUI.framework */; };
E54FA36E3E12B4802B29BC74 /* Pods_PurchasesHybridCommon_PurchasesHybridCommonIntegrationTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 73AF1F3DF376D2EA6B061FDB /* Pods_PurchasesHybridCommon_PurchasesHybridCommonIntegrationTests.framework */; };
F928DE87DE0A86261D51DFA3 /* Pods_PurchasesHybridCommon.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A4FFCDDD40F8CB4D3D00FFB6 /* Pods_PurchasesHybridCommon.framework */; };
FD1425A52C3C444E00323B48 /* StoreKitVersion+HybridAdditions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD1425A42C3C444E00323B48 /* StoreKitVersion+HybridAdditions.swift */; };
Expand All @@ -96,6 +98,13 @@
/* End PBXBuildFile section */

/* Begin PBXContainerItemProxy section */
1D7B76F90118AF9506837FED /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 2DD4B8B124377B470070344F /* Project object */;
proxyType = 1;
remoteGlobalIDString = 4F7974492B5EDDEF003085E7;
remoteInfo = PurchasesHybridCommonUI;
};
2D4FDFD428071F7C00863298 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 2DD4B8B124377B470070344F /* Project object */;
Expand Down Expand Up @@ -230,6 +239,7 @@
A4FFCDDD40F8CB4D3D00FFB6 /* Pods_PurchasesHybridCommon.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_PurchasesHybridCommon.framework; sourceTree = BUILT_PRODUCTS_DIR; };
AA2213E187711A1014A1C874 /* Pods-PurchasesHybridCommon-PurchasesHybridCommonTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-PurchasesHybridCommon-PurchasesHybridCommonTests.debug.xcconfig"; path = "Target Support Files/Pods-PurchasesHybridCommon-PurchasesHybridCommonTests/Pods-PurchasesHybridCommon-PurchasesHybridCommonTests.debug.xcconfig"; sourceTree = "<group>"; };
B3105D79287E3CC200CA935B /* MockSandboxEnvironmentDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockSandboxEnvironmentDetector.swift; sourceTree = "<group>"; };
BE5DB0D9EDF7E18D1D358ABE /* PaywallPresentationStyleTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PaywallPresentationStyleTests.swift; sourceTree = "<group>"; };
BEA47FF2383D59FB41CA66D8 /* Pods-PurchasesHybridCommonUI.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-PurchasesHybridCommonUI.debug.xcconfig"; path = "Target Support Files/Pods-PurchasesHybridCommonUI/Pods-PurchasesHybridCommonUI.debug.xcconfig"; sourceTree = "<group>"; };
C3DF770C345D861DE0CE6DC9 /* Pods-PurchasesHybridCommon-PurchasesHybridCommonTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-PurchasesHybridCommon-PurchasesHybridCommonTests.release.xcconfig"; path = "Target Support Files/Pods-PurchasesHybridCommon-PurchasesHybridCommonTests/Pods-PurchasesHybridCommon-PurchasesHybridCommonTests.release.xcconfig"; sourceTree = "<group>"; };
C48588E24A992EC358A9B0CF /* Pods-PurchasesHybridCommonSwift-PurchasesHybridCommonSwiftTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-PurchasesHybridCommonSwift-PurchasesHybridCommonSwiftTests.debug.xcconfig"; path = "Target Support Files/Pods-PurchasesHybridCommonSwift-PurchasesHybridCommonSwiftTests/Pods-PurchasesHybridCommonSwift-PurchasesHybridCommonSwiftTests.debug.xcconfig"; sourceTree = "<group>"; };
Expand Down Expand Up @@ -277,6 +287,7 @@
files = (
2D6E33242450F68C0022A971 /* PurchasesHybridCommon.framework in Frameworks */,
B1C9A7EEA6961CC6DE22BCAD /* Pods_PurchasesHybridCommon_PurchasesHybridCommonTests.framework in Frameworks */,
E154E4AD206A01BCC76C3976 /* PurchasesHybridCommonUI.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down Expand Up @@ -353,6 +364,7 @@
FD3652E02C53FA7A00B513F6 /* PurchasesAreCompletedByHybridAdditionsTests.swift */,
FDB8426F2E09D43E007DCB4C /* VirtualCurrency+HybridAdditionsTests.swift */,
FDB8426D2E09D3F4007DCB4C /* VirtualCurrencies+HybridAdditionsTests.swift */,
BE5DB0D9EDF7E18D1D358ABE /* PaywallPresentationStyleTests.swift */,
);
path = PurchasesHybridCommonTests;
sourceTree = "<group>";
Expand Down Expand Up @@ -619,6 +631,7 @@
);
dependencies = (
2D6E33262450F68C0022A971 /* PBXTargetDependency */,
F65BF4F895E6FD49191461ED /* PBXTargetDependency */,
);
name = PurchasesHybridCommonTests;
productName = PurchasesHybridCommonTests;
Expand Down Expand Up @@ -1004,6 +1017,7 @@
1E6A6C272E8BED79006C69B9 /* CommonPurchaseParamsTests.swift in Sources */,
FD3652E12C53FA7A00B513F6 /* PurchasesAreCompletedByHybridAdditionsTests.swift in Sources */,
B3105D7A287E3CC200CA935B /* MockSandboxEnvironmentDetector.swift in Sources */,
8D7E9A16717774CB710220ED /* PaywallPresentationStyleTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down Expand Up @@ -1075,6 +1089,12 @@
target = 57FFDE27286A57580089A57B /* PurchasesHybridCommonIntegrationTestApp */;
targetProxy = 57FFDE36286A57940089A57B /* PBXContainerItemProxy */;
};
F65BF4F895E6FD49191461ED /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
name = PurchasesHybridCommonUI;
target = 4F7974492B5EDDEF003085E7 /* PurchasesHybridCommonUI */;
targetProxy = 1D7B76F90118AF9506837FED /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */

/* Begin XCBuildConfiguration section */
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
//
// PaywallPresentationStyleTests.swift
// PurchasesHybridCommonTests
//
// Copyright © 2026 RevenueCat. All rights reserved.
//

#if !os(macOS) && !os(tvOS) && !os(watchOS)

import Quick
import Nimble
import UIKit
@testable import PurchasesHybridCommonUI

@available(iOS 15.0, *)
class PaywallPresentationStyleTests: QuickSpec {

override func spec() {

func resolve(_ options: [String: Any]) -> UIModalPresentationStyle {
PaywallProxy.PaywallPresentationParams.resolveModalPresentationStyle(from: options)
}

let presentationMode = PaywallProxy.PaywallOptionsKeys.presentationMode
let useFullScreenPresentation = PaywallProxy.PaywallOptionsKeys.useFullScreenPresentation

describe("resolveModalPresentationStyle") {

context("when presentationMode is provided") {
it("maps recognized tokens to the matching style") {
expect(resolve([presentationMode: "fullScreen"])) == .fullScreen
expect(resolve([presentationMode: "formSheet"])) == .formSheet
expect(resolve([presentationMode: "automatic"])) == .automatic
expect(resolve([presentationMode: "pageSheet"])) == .pageSheet
}

it("is case-insensitive") {
expect(resolve([presentationMode: "FULLSCREEN"])) == .fullScreen
expect(resolve([presentationMode: "FormSheet"])) == .formSheet
}

it("accepts \"sheet\" as an alias for pageSheet") {
expect(resolve([presentationMode: "sheet"])) == .pageSheet
}

it("takes precedence over the legacy useFullScreenPresentation flag") {
expect(resolve([presentationMode: "formSheet",
useFullScreenPresentation: true])) == .formSheet
}

context("when the value is unrecognized") {
it("falls back to the legacy flag") {
expect(resolve([presentationMode: "banana",
useFullScreenPresentation: true])) == .fullScreen
}

it("defaults to pageSheet when no legacy flag is set") {
expect(resolve([presentationMode: "banana"])) == .pageSheet
}
}

context("when the value is not a String") {
it("falls back to the legacy flag") {
expect(resolve([presentationMode: 42])) == .pageSheet
}
}
}

context("when presentationMode is absent") {
it("uses fullScreen when useFullScreenPresentation is true") {
expect(resolve([useFullScreenPresentation: true])) == .fullScreen
}

it("uses pageSheet when useFullScreenPresentation is false") {
expect(resolve([useFullScreenPresentation: false])) == .pageSheet
}

it("defaults to pageSheet when neither key is present (backwards compatibility)") {
expect(resolve([:])) == .pageSheet
}
}
}
}
}

#endif
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,20 @@ import UIKit
/// This is useful for ensuring the paywall covers the entire screen in landscape orientation,
/// where `.pageSheet` leaves visible space on the sides.
/// Defaults to `false` to preserve backwards compatibility.
///
/// Prefer ``presentationMode`` for new integrations, which supports all presentation styles.
/// When ``presentationMode`` is provided it takes precedence over this flag.
@objc public static let useFullScreenPresentation = "useFullScreenPresentation"
/// The `UIModalPresentationStyle` to present the paywall with. One of `"pageSheet"`, `"formSheet"`,
/// `"fullScreen"` or `"automatic"`.
///
/// This is the preferred way for hybrid SDKs to control presentation, and takes precedence over
/// ``useFullScreenPresentation``. When neither is provided, the paywall is presented with `.pageSheet`
/// to preserve backwards compatibility.
///
/// Note: on iPad, `.pageSheet` covers most of the screen while `.formSheet` presents a smaller,
/// centered modal. `.automatic` resolves to `.formSheet` on iOS 18+ and `.pageSheet` on earlier versions.
@objc public static let presentationMode = "presentationMode"
}

@objc public class PresentedOfferingContextKeys: NSObject {
Expand Down Expand Up @@ -289,7 +302,7 @@ import UIKit
}

controller.delegate = self
controller.modalPresentationStyle = params.useFullScreenPresentation ? .fullScreen : .pageSheet
controller.modalPresentationStyle = params.modalPresentationStyle
controller.view.backgroundColor = .systemBackground

if let purchaseLogicBridge {
Expand Down Expand Up @@ -317,7 +330,8 @@ import UIKit
return windowScene.keyWindow?.rootViewController
}

private enum Content {
// `internal` (rather than `private`) because it is referenced by the now-`internal` `PaywallPresentationParams`.
enum Content {

case offering(Offering)
case offeringIdentifierWithPresentedOfferingContext(String, presentedOfferingContext: PresentedOfferingContext)
Expand Down Expand Up @@ -381,19 +395,20 @@ import UIKit
}

/// Parsed paywall presentation parameters extracted from an options dictionary.
private struct PaywallPresentationParams {
/// `internal` (rather than `private`) so `resolveModalPresentationStyle` can be unit tested via `@testable import`.
struct PaywallPresentationParams {
let displayCloseButton: Bool
let fontProvider: PaywallFontProvider
let shouldBlockTouchEvents: Bool
let customVariables: [String: Any]?
let useFullScreenPresentation: Bool
let modalPresentationStyle: UIModalPresentationStyle
let content: Content

init(options: [String: Any], content: Content) {
self.displayCloseButton = options[PaywallOptionsKeys.displayCloseButton] as? Bool ?? false
self.shouldBlockTouchEvents = options[PaywallOptionsKeys.shouldBlockTouchEvents] as? Bool ?? false
self.customVariables = options[PaywallOptionsKeys.customVariables] as? [String: Any]
self.useFullScreenPresentation = options[PaywallOptionsKeys.useFullScreenPresentation] as? Bool ?? false
self.modalPresentationStyle = Self.resolveModalPresentationStyle(from: options)
self.content = content

if let fontName = options[PaywallOptionsKeys.fontName] as? String {
Expand All @@ -402,6 +417,29 @@ import UIKit
self.fontProvider = DefaultPaywallFontProvider()
}
}

/// Resolves the presentation style from the options dictionary.
///
/// `presentationMode` takes precedence when present. Otherwise falls back to the legacy
/// `useFullScreenPresentation` boolean, defaulting to `.pageSheet` to preserve backwards
/// compatibility for existing hybrid integrations that pass neither key.
///
/// `internal` (rather than `private`) so it can be unit tested via `@testable import`.
static func resolveModalPresentationStyle(from options: [String: Any]) -> UIModalPresentationStyle {
if let mode = options[PaywallOptionsKeys.presentationMode] as? String {
switch mode.lowercased() {
case "fullscreen": return .fullScreen
case "formsheet": return .formSheet
case "automatic": return .automatic
// "sheet" is accepted as an alias for "pageSheet" to match hybrid SDK tokens (e.g. Unity).
Comment thread
facumenzella marked this conversation as resolved.
case "pagesheet", "sheet": return .pageSheet
default: break
}
}

let useFullScreenPresentation = options[PaywallOptionsKeys.useFullScreenPresentation] as? Bool ?? false
return useFullScreenPresentation ? .fullScreen : .pageSheet
}
}
}

Expand Down