Skip to content
Draft
Show file tree
Hide file tree
Changes from 5 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? {
Int(filter { "0"..."9" ~= $0 })
}
}
11 changes: 9 additions & 2 deletions Sources/Misc/SystemInfo.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ import WatchKit
import AppKit
#endif

// swiftlint:disable file_length
class SystemInfo {
// swiftlint:disable file_length missing_docs
public class SystemInfo {

// swiftlint:disable:next force_unwrapping
static let appleSubscriptionsURL = URL(string: "https://apps.apple.com/account/subscriptions")!
Expand Down Expand Up @@ -423,3 +423,10 @@ private extension SystemInfo {

#endif
}

@_spi(Internal)
public enum InternalSystemInfo {
public static func appVersion() -> String {
return SystemInfo.appVersion
}
}
28 changes: 28 additions & 0 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,12 +98,17 @@ 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: .value)
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)
Expand Down Expand Up @@ -137,6 +145,14 @@ public extension PaywallComponent {
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: .value)
if let versionInt = SystemInfo.appVersion.extractNumber() {
self = .appVersion(operand, versionInt)
} else {
self = .unsupported
}
}
} else {
self = .unsupported
Expand Down Expand Up @@ -166,6 +182,7 @@ public extension PaywallComponent {
case promoOffer = "promo_offer"
case anyPackageContainsPromoOffer = "promo_offer_available"
case selected
case appVersion = "app_version"

}

Expand All @@ -186,6 +203,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,46 @@ 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 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)", "value": "\(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