Skip to content
Draft
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
12 changes: 12 additions & 0 deletions RevenueCat.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,10 @@
1621701C2EBE5581008ACFE9 /* Locale+Comparison.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1621701B2EBE5581008ACFE9 /* Locale+Comparison.swift */; };
162216CE2EDF8D1500C36EE2 /* ScreenConditionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 162216CD2EDF8D1500C36EE2 /* ScreenConditionTests.swift */; };
162216CF2EDF8D1500C36EE2 /* ScreenConditionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 162216CD2EDF8D1500C36EE2 /* ScreenConditionTests.swift */; };
162216DF2EE08CFB00C36EE2 /* String+extractNumber.swift in Sources */ = {isa = PBXBuildFile; fileRef = 162216DE2EE08CFB00C36EE2 /* String+extractNumber.swift */; };
162216E22EE08EA500C36EE2 /* String+extractNumber.swift in Sources */ = {isa = PBXBuildFile; fileRef = 162216DE2EE08CFB00C36EE2 /* String+extractNumber.swift */; };
162216E42EE0947A00C36EE2 /* String+ExtractNumberTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 162216E32EE0947A00C36EE2 /* String+ExtractNumberTests.swift */; };
162216E52EE0947A00C36EE2 /* String+ExtractNumberTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 162216E32EE0947A00C36EE2 /* String+ExtractNumberTests.swift */; };
1622D3FB2E900DE000C20E3C /* ChecksumTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1622D3FA2E900DE000C20E3C /* ChecksumTests.swift */; };
1622D3FD2E900F8200C20E3C /* Checksum.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1622D3FC2E900F8200C20E3C /* Checksum.swift */; };
1622D40E2E90189F00C20E3C /* URLWithValidation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1622D40D2E90189F00C20E3C /* URLWithValidation.swift */; };
Expand Down Expand Up @@ -1542,6 +1546,8 @@
162170142EBE50F0008ACFE9 /* LocaleComparisonTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocaleComparisonTests.swift; sourceTree = "<group>"; };
1621701B2EBE5581008ACFE9 /* Locale+Comparison.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Locale+Comparison.swift"; sourceTree = "<group>"; };
162216CD2EDF8D1500C36EE2 /* ScreenConditionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenConditionTests.swift; sourceTree = "<group>"; };
162216DE2EE08CFB00C36EE2 /* String+extractNumber.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+extractNumber.swift"; sourceTree = "<group>"; };
162216E32EE0947A00C36EE2 /* String+ExtractNumberTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+ExtractNumberTests.swift"; sourceTree = "<group>"; };
1622D3FA2E900DE000C20E3C /* ChecksumTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChecksumTests.swift; sourceTree = "<group>"; };
1622D3FC2E900F8200C20E3C /* Checksum.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Checksum.swift; sourceTree = "<group>"; };
1622D40D2E90189F00C20E3C /* URLWithValidation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLWithValidation.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -3855,6 +3861,7 @@
2DDA3E4524DB0B4500EDFE5B /* Misc */ = {
isa = PBXGroup;
children = (
162216DE2EE08CFB00C36EE2 /* String+extractNumber.swift */,
35F38B492C32BC2800CD29FD /* Locale */,
57F3C0CA29B7A08F0004FD7E /* Codable */,
57F3C0CB29B7A0B10004FD7E /* Concurrency */,
Expand Down Expand Up @@ -4158,6 +4165,7 @@
4FEF41AC2B4F301800CD699F /* MacAppStoreDetectorTests.swift */,
2C7F0AD42B8EEF0B00381179 /* RateLimiterTests.swift */,
1EF46BC52D9C1FA7005C94A6 /* PurchasesSystemInfoTests.swift */,
162216E32EE0947A00C36EE2 /* String+ExtractNumberTests.swift */,
);
path = Misc;
sourceTree = "<group>";
Expand Down Expand Up @@ -6951,6 +6959,7 @@
2C2AEB3F2CA7235300A50F38 /* PaywallPurchaseButtonComponent.swift in Sources */,
4FC883812AA7A2BD00A3DE03 /* ProcessInfo+Extensions.swift in Sources */,
57488B7F29CB70E50000EE7E /* ProductEntitlementMapping.swift in Sources */,
162216DF2EE08CFB00C36EE2 /* String+extractNumber.swift in Sources */,
FDE57A9E2DF8783000101CE2 /* VirtualCurrenciesCallback.swift in Sources */,
B34605CF279A6E380031CA74 /* GetOfferingsOperation.swift in Sources */,
2DDF41AC24F6F37C005BC22D /* ASN1Container.swift in Sources */,
Expand Down Expand Up @@ -7253,6 +7262,7 @@
4F05876F2A5DE03F00E9A834 /* PaywallDataTests.swift in Sources */,
2DDF41CC24F6F4C3005BC22D /* AppleReceiptBuilderTests.swift in Sources */,
903A06612EB4B728009B9CE4 /* MockEventsManager.swift in Sources */,
162216E42EE0947A00C36EE2 /* String+ExtractNumberTests.swift in Sources */,
03F446552D303E350046129A /* PaddingPropertyTests.swift in Sources */,
575A8EE52922C9F300936709 /* MockStoreKit2TransactionListenerDelegate.swift in Sources */,
4F9BB63F2A7AFB72001C120D /* MockPayment.swift in Sources */,
Expand Down Expand Up @@ -7372,6 +7382,7 @@
351B516226D44BEE00BD2BD7 /* CustomerInfoManagerTests.swift in Sources */,
1622D3FB2E900DE000C20E3C /* ChecksumTests.swift in Sources */,
5748008C29BFC6660032F001 /* SignatureVerificationHTTPClientTests.swift in Sources */,
162216E22EE08EA500C36EE2 /* String+extractNumber.swift in Sources */,
2C8EC6DF2CCD27A500D6CCF8 /* PartialComponentTests.swift in Sources */,
351B51A326D450BC00BD2BD7 /* DictionaryExtensionsTests.swift in Sources */,
4FB2B5512AA7DBA40087EDB5 /* MockFileHandler.swift in Sources */,
Expand Down Expand Up @@ -7812,6 +7823,7 @@
5798C9722DF1985700F44400 /* DiscountsHandler.swift in Sources */,
5798C9732DF1985700F44400 /* PurchaseHistoryViewModel.swift in Sources */,
5798C9742DF1985700F44400 /* Transaction.swift in Sources */,
162216E52EE0947A00C36EE2 /* String+ExtractNumberTests.swift in Sources */,
5798C9752DF1985700F44400 /* SubscriptionDetailView.swift in Sources */,
5798C9772DF1985700F44400 /* FallbackNoSubscriptionsView.swift in Sources */,
5798C9782DF1985700F44400 /* CustomerCenterNavigationOptions.swift in Sources */,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ extension PresentedPartial {
/// - isEligibleForPromoOffer: Whether the selected package is promo-eligible.
/// - anyPackageHasIntroOffer: Whether any package in the context exposes an intro offer.
/// - anyPackageHasPromoOffer: Whether any package in the context exposes a promo offer.
/// - appVersionInt: The app version as an integer (dots removed from version string).
/// - presentedOverrides: Override configurations to apply
/// - Returns: Configured partial component
// swiftlint:disable:next function_parameter_count
Expand All @@ -60,6 +61,7 @@ extension PresentedPartial {
isEligibleForPromoOffer: Bool,
anyPackageHasIntroOffer: Bool = false,
anyPackageHasPromoOffer: Bool = false,
appVersionInt: Int = InternalSystemInfo.appVersion().extractNumber() ?? 0,
selectedPackage: Package?,
with presentedOverrides: PresentedOverrides<Self>?
) -> Self? {
Expand All @@ -77,6 +79,7 @@ extension PresentedPartial {
isEligibleForPromoOffer: isEligibleForPromoOffer,
anyPackageHasIntroOffer: anyPackageHasIntroOffer,
anyPackageHasPromoOffer: anyPackageHasPromoOffer,
appVersionInt: appVersionInt,
selectedPackage: selectedPackage
) {
presentedPartial = Self.combine(presentedPartial, with: presentedOverride.properties)
Expand All @@ -94,6 +97,7 @@ extension PresentedPartial {
isEligibleForPromoOffer: Bool,
anyPackageHasIntroOffer: Bool,
anyPackageHasPromoOffer: Bool,
appVersionInt: Int,
selectedPackage: Package?
) -> Bool {
// Early return when any condition evaluates to false
Expand Down Expand Up @@ -175,6 +179,31 @@ extension PresentedPartial {
if state != .selected {
return false
}
case .appVersion(let operand, let value):
switch operand {
case .lessThan:
if !(appVersionInt < value) {
return false
}
case .lessThanOrEqual:
if !(appVersionInt <= value) {
return false
}
case .equal:
if !(appVersionInt == value) {
return false
}
case .greaterThan:
if !(appVersionInt > value) {
return false
}
case .greaterThanOrEqual:
if !(appVersionInt >= value) {
return false
}
@unknown default:
return false
}
case .unsupported:
return true // ignore unsupported case and show partial
@unknown default:
Expand Down
22 changes: 22 additions & 0 deletions Sources/Misc/String+extractNumber.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
//
// Copyright RevenueCat Inc. All Rights Reserved.
//
// Licensed under the MIT License (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://opensource.org/licenses/MIT
//
// String+extractNumber.swift
//
// Created by Jacob Zivan Rakidzich on 12/3/25.

import Foundation

@_spi(Internal) public extension String {

/// Take all numbers out of a string and return an Int if present
func extractNumber() -> Int? {
return Int(filter { "0"..."9" ~= $0 })
}
}
9 changes: 9 additions & 0 deletions Sources/Misc/SystemInfo.swift
Original file line number Diff line number Diff line change
Expand Up @@ -423,3 +423,12 @@ private extension SystemInfo {

#endif
}

// swiftlint:disable missing_docs

@_spi(Internal)
public enum InternalSystemInfo {
public static func appVersion() -> String {
return SystemInfo.appVersion
}
}
109 changes: 70 additions & 39 deletions Sources/Paywalls/Components/Common/ComponentOverrides.swift
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@ public extension PaywallComponent {
/// Is the current component selected?
case selected

/// Compares the app version (as integer with dots removed) against [value]
case appVersion(ComparisonOperatorType, Int)

// For unknown cases
case unsupported

Expand Down Expand Up @@ -95,52 +98,67 @@ public extension PaywallComponent {
try container.encode(value, forKey: .value)
case .selected:
try container.encode(ConditionType.selected.rawValue, forKey: .type)
case let .appVersion(operand, value):
try container.encode(ConditionType.appVersion.rawValue, forKey: .type)
try container.encode(operand, forKey: .operator)
try container.encode(String(value), forKey: .iosVersion)
case .unsupported:
// Encode a default value for unsupported
try container.encode("unknown", forKey: .type)
}
}

// swiftlint:disable:next cyclomatic_complexity
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let rawValue = try container.decode(String.self, forKey: .type)

if let conditionType = ConditionType(rawValue: rawValue) {
switch conditionType {
case .orientation:
let operand = try container.decode(ArrayOperatorType.self, forKey: .operator)
let orientations = try container.decode([OrientationType].self, forKey: .orientations)
self = .orientation(operand, orientations)
case .screenSize:
let operand = try container.decode(ArrayOperatorType.self, forKey: .operator)
let sizes = try container.decode([String].self, forKey: .sizes)
self = .screenSize(operand, sizes)
case .selectedPackage:
let operand = try container.decode(ArrayOperatorType.self, forKey: .operator)
let packages = try container.decode([String].self, forKey: .packages)
self = .selectedPackage(operand, packages)
case .introOffer:
let operand = try container.decodeIfPresent(EqualityOperatorType.self, forKey: .operator) ?? .equals
let value = try container.decodeIfPresent(Bool.self, forKey: .value) ?? true
self = .introOffer(operand, value)
case .anyPackageContainsIntroOffer:
let operand = try container.decode(EqualityOperatorType.self, forKey: .operator)
let value = try container.decode(Bool.self, forKey: .value)
self = .anyPackageContainsIntroOffer(operand, value)
case .promoOffer:
let operand = try container.decode(EqualityOperatorType.self, forKey: .operator)
let value = try container.decode(Bool.self, forKey: .value)
self = .promoOffer(operand, value)
case .anyPackageContainsPromoOffer:
let operand = try container.decode(EqualityOperatorType.self, forKey: .operator)
let value = try container.decode(Bool.self, forKey: .value)
self = .anyPackageContainsPromoOffer(operand, value)
case .selected:
self = .selected
}
} else {
self = .unsupported
}
do {
let container = try decoder.container(keyedBy: CodingKeys.self)
let rawValue = try container.decode(String.self, forKey: .type)

if let conditionType = ConditionType(rawValue: rawValue) {
switch conditionType {
case .orientation:
let operand = try container.decode(ArrayOperatorType.self, forKey: .operator)
let orientations = try container.decode([OrientationType].self, forKey: .orientations)
self = .orientation(operand, orientations)
case .screenSize:
let operand = try container.decode(ArrayOperatorType.self, forKey: .operator)
let sizes = try container.decode([String].self, forKey: .sizes)
self = .screenSize(operand, sizes)
case .selectedPackage:
let operand = try container.decode(ArrayOperatorType.self, forKey: .operator)
let packages = try container.decode([String].self, forKey: .packages)
self = .selectedPackage(operand, packages)
case .introOffer:
let operand = try container
.decodeIfPresent(EqualityOperatorType.self, forKey: .operator) ?? .equals
let value = try container.decodeIfPresent(Bool.self, forKey: .value) ?? true
self = .introOffer(operand, value)
case .anyPackageContainsIntroOffer:
let operand = try container.decode(EqualityOperatorType.self, forKey: .operator)
let value = try container.decode(Bool.self, forKey: .value)
self = .anyPackageContainsIntroOffer(operand, value)
case .promoOffer:
let operand = try container.decode(EqualityOperatorType.self, forKey: .operator)
let value = try container.decode(Bool.self, forKey: .value)
self = .promoOffer(operand, value)
case .anyPackageContainsPromoOffer:
let operand = try container.decode(EqualityOperatorType.self, forKey: .operator)
let value = try container.decode(Bool.self, forKey: .value)
self = .anyPackageContainsPromoOffer(operand, value)
case .selected:
self = .selected
case .appVersion:
let operand = try container.decode(ComparisonOperatorType.self, forKey: .operator)
let versionString = try container.decode(String.self, forKey: .iosVersion)

if let versionInt = versionString.extractNumber() {
self = .appVersion(operand, versionInt)
} else {
self = .unsupported
}
}
} else { self = .unsupported }
} catch { self = .unsupported }
}

// swiftlint:disable:next nesting
Expand All @@ -152,6 +170,7 @@ public extension PaywallComponent {
case orientations
case packages
case value
case iosVersion

}

Expand All @@ -166,6 +185,7 @@ public extension PaywallComponent {
case promoOffer = "promo_offer"
case anyPackageContainsPromoOffer = "promo_offer_available"
case selected
case appVersion = "app_version"

}

Expand All @@ -186,6 +206,17 @@ public extension PaywallComponent {

}

// swiftlint:disable:next nesting
public enum ComparisonOperatorType: String, Codable, Sendable, Hashable, Equatable {

case lessThan = "<"
case lessThanOrEqual = "<="
case equal = "="
case greaterThan = ">"
case greaterThanOrEqual = ">="

}

// swiftlint:disable:next nesting
public enum OrientationType: String, Codable, Sendable, Hashable, Equatable {

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,73 @@ import XCTest

@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
final class ComponentOverridesTests: TestCase {
typealias ComparisonOperatorType = PaywallComponent.Condition.ComparisonOperatorType

func test_defaultsToUnsupportedOnUnknownCondition() throws {
let json = """
[
{
"conditions": [
{ "type": "Some-unknown-condition", "operator": "<", "value": "12" }
],
"properties": { }
}
]
""".data(using: .utf8)!

let overrides = try JSONDecoder.default.decode(
PaywallComponent.ComponentOverrides<PaywallComponent.PartialStackComponent>.self,
from: json
)

let condition = try XCTUnwrap(overrides.first?.conditions.first)

switch condition {
case .unsupported:
XCTAssert(true) // success
default:
fail("Expected app version condition")
}
}

func testDecodesAppVersionCondition() throws {
let testCases = [
(ComparisonOperatorType.lessThan, "12.12.12", "<", 121212),
(ComparisonOperatorType.equal, "12.01.120", "=", 1201120),
(ComparisonOperatorType.greaterThan, "1", ">", 1),
(ComparisonOperatorType.greaterThanOrEqual, "100.100.100", ">=", 100100100),
(ComparisonOperatorType.lessThanOrEqual, "001.101.101", "<=", 1101101)
]

try testCases.forEach { expectedOperand, value, operand, expectedValue in
let json = """
[
{
"conditions": [
{ "type": "app_version", "operator": "\(operand)", "ios_version": "\(value)" }
],
"properties": { }
}
]
""".data(using: .utf8)!

let overrides = try JSONDecoder.default.decode(
PaywallComponent.ComponentOverrides<PaywallComponent.PartialStackComponent>.self,
from: json
)

let condition = try XCTUnwrap(overrides.first?.conditions.first)

switch condition {
case let .appVersion(operatorType, value):
expect(operatorType) == expectedOperand
expect(value) == expectedValue
default:
fail("Expected app version condition")
}

}
}

func testDecodesIntroOfferCondition() throws {
let json = """
Expand Down
Loading