diff --git a/RevenueCat.xcodeproj/project.pbxproj b/RevenueCat.xcodeproj/project.pbxproj index 803bdf800c..19d9a3b266 100644 --- a/RevenueCat.xcodeproj/project.pbxproj +++ b/RevenueCat.xcodeproj/project.pbxproj @@ -109,6 +109,7 @@ 16DA8ECD2E4EE33700283940 /* ImageComponentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16DA8ECC2E4EE33700283940 /* ImageComponentTests.swift */; }; 16DA8EDF2E4F6A6600283940 /* PaywallVideoComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16DA8EDE2E4F6A6600283940 /* PaywallVideoComponent.swift */; }; 16DA8EF42E4F7A2500283940 /* VideoComponentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16DA8EF32E4F7A2500283940 /* VideoComponentTests.swift */; }; + FA4A2DF41FC434A9401F08CC /* PaywallWebViewAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D0C57ECDF0E7F1A5B4418FF /* PaywallWebViewAPI.swift */; }; 16DA8F1E2E4FB6E200283940 /* ImageComponent.json in Resources */ = {isa = PBXBuildFile; fileRef = 16DA8F1B2E4FB6E200283940 /* ImageComponent.json */; }; 16DA8F1F2E4FB6E200283940 /* VideoComponent.json in Resources */ = {isa = PBXBuildFile; fileRef = 16DA8F1C2E4FB6E200283940 /* VideoComponent.json */; }; 16E146AD2E99F3480089B609 /* TransactionNotifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16E146AB2E99F1E20089B609 /* TransactionNotifications.swift */; }; @@ -1671,6 +1672,8 @@ 16DA8ECC2E4EE33700283940 /* ImageComponentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageComponentTests.swift; sourceTree = ""; }; 16DA8EDE2E4F6A6600283940 /* PaywallVideoComponent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallVideoComponent.swift; sourceTree = ""; }; 16DA8EF32E4F7A2500283940 /* VideoComponentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoComponentTests.swift; sourceTree = ""; }; + 9D0C57ECDF0E7F1A5B4418FF /* PaywallWebViewAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallWebViewAPI.swift; sourceTree = ""; }; + 686562C08D953A27986DB276 /* PaywallWebViewValueTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallWebViewValueTests.swift; sourceTree = ""; }; 16DA8F1B2E4FB6E200283940 /* ImageComponent.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = ImageComponent.json; sourceTree = ""; }; 16DA8F1C2E4FB6E200283940 /* VideoComponent.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = VideoComponent.json; sourceTree = ""; }; 16E146AB2E99F1E20089B609 /* TransactionNotifications.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransactionNotifications.swift; sourceTree = ""; }; @@ -3232,6 +3235,7 @@ 030890822D2B77DD0069677B /* PaywallsV2 */ = { isa = PBXGroup; children = ( + 686562C08D953A27986DB276 /* PaywallWebViewValueTests.swift */, 802593842FE07057005AF6DF /* PaywallStateStoreTests.swift */, 16A9F7B62FAA22D0008E8A4D /* VideoComponentViewTests.swift */, A9D45E4EAFAD4C0385BEDFB3 /* VideoPlayerViewTests.swift */, @@ -3365,6 +3369,14 @@ path = Video; sourceTree = ""; }; + EB23E44596DD9714E81840CF /* WebView */ = { + isa = PBXGroup; + children = ( + 9D0C57ECDF0E7F1A5B4418FF /* PaywallWebViewAPI.swift */, + ); + path = WebView; + sourceTree = ""; + }; 1699BD5C2EEFBEEC002001FB /* DefaultPaywall */ = { isa = PBXGroup; children = ( @@ -3456,6 +3468,7 @@ 0387D4AD2EC58AA5008E4A6B /* Countdown */, E1C0A00BBEEF0001CCDD0001 /* Header */, 164681CD2E6B577600854AA5 /* Video */, + EB23E44596DD9714E81840CF /* WebView */, 2C7457472CEA66AB004ACE52 /* ComponentsView.swift */, 7707A94A2CAD936A006E0313 /* Button */, 03C06FC82D479C6300600693 /* Carousel */, @@ -8311,6 +8324,7 @@ 164681D02E6B577600854AA5 /* VideoComponentView.swift in Sources */, 164681D12E6B577600854AA5 /* VideoPlayerViewUIView.swift in Sources */, 164681D62E6B577600854AA5 /* VideoPlayerLayerUIView.swift in Sources */, + FA4A2DF41FC434A9401F08CC /* PaywallWebViewAPI.swift in Sources */, DBAA1FC22F8D4ED8000E8C81 /* HeaderNestedHeroZLayerSafeAreaPreview.swift in Sources */, 164681D22E6B577600854AA5 /* VideoComponentViewModel.swift in Sources */, 2C7457422CE81107004ACE52 /* IntroOfferEligibilityContext.swift in Sources */, diff --git a/RevenueCatUI/Templates/V2/Components/WebView/PaywallWebViewAPI.swift b/RevenueCatUI/Templates/V2/Components/WebView/PaywallWebViewAPI.swift new file mode 100644 index 0000000000..25e0d16a6c --- /dev/null +++ b/RevenueCatUI/Templates/V2/Components/WebView/PaywallWebViewAPI.swift @@ -0,0 +1,112 @@ +import Foundation + +#if !os(tvOS) // For Paywalls V2 + +/// A JSON-compatible value exchanged between a Paywalls V2 `web_view` component and your app. +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +struct PaywallWebViewValue: Sendable, Equatable, Hashable, Codable { + + private enum Storage: Sendable, Equatable, Hashable { + case string(String) + // The web peer uses JavaScript `Number` values, so there is no integer precision to preserve. + case number(Double) + case bool(Bool) + case array([PaywallWebViewValue]) + case object([String: PaywallWebViewValue]) + case null + } + + private let storage: Storage + + private init(_ storage: Storage) { + self.storage = storage + } + + /// Creates a string value. + static func string(_ value: String) -> Self { Self(.string(value)) } + + /// Creates a numeric value. Non-finite values normalize to ``null`` because JSON cannot encode them. + static func number(_ value: Double) -> Self { value.isFinite ? Self(.number(value)) : .null } + + /// Creates a boolean value. + static func bool(_ value: Bool) -> Self { Self(.bool(value)) } + + /// Creates an array value. + static func array(_ value: [PaywallWebViewValue]) -> Self { Self(.array(value)) } + + /// Creates an object (dictionary) value. + static func object(_ value: [String: PaywallWebViewValue]) -> Self { Self(.object(value)) } + + /// A null value. + static var null: Self { Self(.null) } + + var stringValue: String? { + if case .string(let value) = self.storage { return value } + return nil + } + + var numberValue: Double? { + if case .number(let value) = self.storage { return value } + return nil + } + + var boolValue: Bool? { + if case .bool(let value) = self.storage { return value } + return nil + } + + var arrayValue: [PaywallWebViewValue]? { + if case .array(let value) = self.storage { return value } + return nil + } + + var objectValue: [String: PaywallWebViewValue]? { + if case .object(let value) = self.storage { return value } + return nil + } + + var isNull: Bool { + if case .null = self.storage { return true } + return false + } + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + + if container.decodeNil() { + self = .null + } else if let value = try? container.decode(Bool.self) { + self = .bool(value) + } else if let value = try? container.decode(Double.self) { + self = .number(value) + } else if let value = try? container.decode(String.self) { + self = .string(value) + } else if let value = try? container.decode([PaywallWebViewValue].self) { + self = .array(value) + } else { + self = .object(try container.decode([String: PaywallWebViewValue].self)) + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + + switch self.storage { + case .string(let value): + try container.encode(value) + case .number(let value): + try container.encode(value) + case .bool(let value): + try container.encode(value) + case .array(let value): + try container.encode(value) + case .object(let value): + try container.encode(value) + case .null: + try container.encodeNil() + } + } + +} + +#endif diff --git a/Tests/RevenueCatUITests/PaywallsV2/PaywallWebViewValueTests.swift b/Tests/RevenueCatUITests/PaywallsV2/PaywallWebViewValueTests.swift new file mode 100644 index 0000000000..a593eeb9c5 --- /dev/null +++ b/Tests/RevenueCatUITests/PaywallsV2/PaywallWebViewValueTests.swift @@ -0,0 +1,59 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// + +@testable import RevenueCatUI +import XCTest + +#if !os(tvOS) + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +final class PaywallWebViewValueTests: TestCase { + + func testFactoryAccessorsAndCodableRoundTrip() throws { + let value: PaywallWebViewValue = .object([ + "string": .string("value"), + "number": .number(1.25), + "bool": .bool(true), + "array": .array([.null, .string("x")]), + "object": .object(["nested": .bool(false)]), + "null": .null + ]) + + let decoded = try JSONDecoder().decode( + PaywallWebViewValue.self, + from: try JSONEncoder().encode(value) + ) + + XCTAssertEqual(decoded, value) + XCTAssertEqual(decoded.objectValue?["string"]?.stringValue, "value") + XCTAssertEqual(decoded.objectValue?["number"]?.numberValue, 1.25) + XCTAssertEqual(decoded.objectValue?["bool"]?.boolValue, true) + XCTAssertTrue(decoded.objectValue?["null"]?.isNull == true) + } + + func testNumberBoolDisambiguation() throws { + let decoded = try JSONDecoder().decode( + PaywallWebViewValue.self, + from: Data(#"{"bool":true,"number":1}"#.utf8) + ) + + XCTAssertEqual(decoded.objectValue?["bool"]?.boolValue, true) + XCTAssertNil(decoded.objectValue?["bool"]?.numberValue) + XCTAssertEqual(decoded.objectValue?["number"]?.numberValue, 1) + } + + func testNonFiniteNumbersNormalizeToNullAndEncode() throws { + XCTAssertTrue(PaywallWebViewValue.number(.nan).isNull) + XCTAssertTrue(PaywallWebViewValue.number(.infinity).isNull) + XCTAssertTrue(PaywallWebViewValue.number(-.infinity).isNull) + XCTAssertNil(PaywallWebViewValue.number(.nan).numberValue) + + let value: PaywallWebViewValue = .object(["bad": .number(.nan)]) + XCTAssertNoThrow(try JSONEncoder().encode(value)) + XCTAssertEqual(PaywallWebViewValue.number(-0.0), .number(0.0)) + } + +} + +#endif