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
14 changes: 14 additions & 0 deletions RevenueCat.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -1671,6 +1672,8 @@
16DA8ECC2E4EE33700283940 /* ImageComponentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageComponentTests.swift; sourceTree = "<group>"; };
16DA8EDE2E4F6A6600283940 /* PaywallVideoComponent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallVideoComponent.swift; sourceTree = "<group>"; };
16DA8EF32E4F7A2500283940 /* VideoComponentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoComponentTests.swift; sourceTree = "<group>"; };
9D0C57ECDF0E7F1A5B4418FF /* PaywallWebViewAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallWebViewAPI.swift; sourceTree = "<group>"; };
686562C08D953A27986DB276 /* PaywallWebViewValueTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallWebViewValueTests.swift; sourceTree = "<group>"; };
16DA8F1B2E4FB6E200283940 /* ImageComponent.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = ImageComponent.json; sourceTree = "<group>"; };
16DA8F1C2E4FB6E200283940 /* VideoComponent.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = VideoComponent.json; sourceTree = "<group>"; };
16E146AB2E99F1E20089B609 /* TransactionNotifications.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransactionNotifications.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -3232,6 +3235,7 @@
030890822D2B77DD0069677B /* PaywallsV2 */ = {
isa = PBXGroup;
children = (
686562C08D953A27986DB276 /* PaywallWebViewValueTests.swift */,
802593842FE07057005AF6DF /* PaywallStateStoreTests.swift */,
16A9F7B62FAA22D0008E8A4D /* VideoComponentViewTests.swift */,
A9D45E4EAFAD4C0385BEDFB3 /* VideoPlayerViewTests.swift */,
Expand Down Expand Up @@ -3365,6 +3369,14 @@
path = Video;
sourceTree = "<group>";
};
EB23E44596DD9714E81840CF /* WebView */ = {
isa = PBXGroup;
children = (
9D0C57ECDF0E7F1A5B4418FF /* PaywallWebViewAPI.swift */,
);
path = WebView;
sourceTree = "<group>";
};
1699BD5C2EEFBEEC002001FB /* DefaultPaywall */ = {
isa = PBXGroup;
children = (
Expand Down Expand Up @@ -3456,6 +3468,7 @@
0387D4AD2EC58AA5008E4A6B /* Countdown */,
E1C0A00BBEEF0001CCDD0001 /* Header */,
164681CD2E6B577600854AA5 /* Video */,
EB23E44596DD9714E81840CF /* WebView */,
2C7457472CEA66AB004ACE52 /* ComponentsView.swift */,
7707A94A2CAD936A006E0313 /* Button */,
03C06FC82D479C6300600693 /* Carousel */,
Expand Down Expand Up @@ -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 */,
Expand Down
112 changes: 112 additions & 0 deletions RevenueCatUI/Templates/V2/Components/WebView/PaywallWebViewAPI.swift
Original file line number Diff line number Diff line change
@@ -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)
Comment thread
JZDesign marked this conversation as resolved.
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
Original file line number Diff line number Diff line change
@@ -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
Loading