diff --git a/RevenueCat.xcodeproj/project.pbxproj b/RevenueCat.xcodeproj/project.pbxproj index 0123759d71..2a97cfe359 100644 --- a/RevenueCat.xcodeproj/project.pbxproj +++ b/RevenueCat.xcodeproj/project.pbxproj @@ -108,7 +108,9 @@ 16DA8EC92E4EE24100283940 /* JsonLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16DA8EC82E4EE24100283940 /* JsonLoader.swift */; }; 16DA8ECD2E4EE33700283940 /* ImageComponentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16DA8ECC2E4EE33700283940 /* ImageComponentTests.swift */; }; 16DA8EDF2E4F6A6600283940 /* PaywallVideoComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16DA8EDE2E4F6A6600283940 /* PaywallVideoComponent.swift */; }; + 66EAEED2B259D54A9E789F4A /* PaywallWebViewComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14C1ACED1916C08B0E8E2776 /* PaywallWebViewComponent.swift */; }; 16DA8EF42E4F7A2500283940 /* VideoComponentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16DA8EF32E4F7A2500283940 /* VideoComponentTests.swift */; }; + E7EB64E9D44783DD16A28B06 /* WebViewComponentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7EB64E9D44783DD16A28B05 /* WebViewComponentTests.swift */; }; FC5AE63E8F81C3EC2FFDA408 /* WebViewOriginPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28661C6CE3F64078A0138080 /* WebViewOriginPolicy.swift */; }; FA4A2DF41FC434A9401F08CC /* PaywallWebViewValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D0C57ECDF0E7F1A5B4418FF /* PaywallWebViewValue.swift */; }; 11CC2EA83C56933E40D8E5D8 /* WebViewEnvelope.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89DD761C9E00E61032AAEB44 /* WebViewEnvelope.swift */; }; @@ -1673,6 +1675,8 @@ 16DA8EC82E4EE24100283940 /* JsonLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JsonLoader.swift; sourceTree = ""; }; 16DA8ECC2E4EE33700283940 /* ImageComponentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageComponentTests.swift; sourceTree = ""; }; 16DA8EDE2E4F6A6600283940 /* PaywallVideoComponent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallVideoComponent.swift; sourceTree = ""; }; + 14C1ACED1916C08B0E8E2776 /* PaywallWebViewComponent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallWebViewComponent.swift; sourceTree = ""; }; + E7EB64E9D44783DD16A28B05 /* WebViewComponentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewComponentTests.swift; sourceTree = ""; }; 16DA8EF32E4F7A2500283940 /* VideoComponentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoComponentTests.swift; sourceTree = ""; }; 28661C6CE3F64078A0138080 /* WebViewOriginPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewOriginPolicy.swift; sourceTree = ""; }; 9E7AD021B4580265DFDF6A02 /* WebViewNavigationPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewNavigationPolicyTests.swift; sourceTree = ""; }; @@ -3242,6 +3246,7 @@ 030890822D2B77DD0069677B /* PaywallsV2 */ = { isa = PBXGroup; children = ( + E7EB64E9D44783DD16A28B05 /* WebViewComponentTests.swift */, 9E7AD021B4580265DFDF6A02 /* WebViewNavigationPolicyTests.swift */, 686562C08D953A27986DB276 /* PaywallWebViewValueTests.swift */, C1B939C7DAAFEA1DE2DE2674 /* WebViewEnvelopeTests.swift */, @@ -5975,6 +5980,7 @@ isa = PBXGroup; children = ( 16DA8EDE2E4F6A6600283940 /* PaywallVideoComponent.swift */, + 14C1ACED1916C08B0E8E2776 /* PaywallWebViewComponent.swift */, 16A7F4C02E563B89001F9FC8 /* Transitions */, 03C730F22D35F13F00297FEC /* PaywallV2CacheWarming.swift */, 2CC791612CC0493600FBE120 /* Common */, @@ -7537,6 +7543,7 @@ 4F8929192A65EF3000A91EA2 /* EnsureNonEmptyCollectionDecodable.swift in Sources */, 757E73CE2F0FE2410066DCDC /* LocalTransactionMetadata.swift in Sources */, 16DA8EDF2E4F6A6600283940 /* PaywallVideoComponent.swift in Sources */, + 66EAEED2B259D54A9E789F4A /* PaywallWebViewComponent.swift in Sources */, 35F82BB626A9B8040051DF03 /* AttributionDataMigrator.swift in Sources */, 4F5F47082AA91F8A005649D8 /* HealthOperation.swift in Sources */, 4FEF41AB2B4F2F3400CD699F /* MapAppStoreDetector.swift in Sources */, @@ -7900,6 +7907,7 @@ 351B51BA26D450E800BD2BD7 /* ProductRequestDataExtensions.swift in Sources */, 57E6C27E29723F9E001AFE98 /* SigningTests.swift in Sources */, 16DA8EF42E4F7A2500283940 /* VideoComponentTests.swift in Sources */, + E7EB64E9D44783DD16A28B06 /* WebViewComponentTests.swift in Sources */, 574A2F3F282D75E300150D40 /* OfferingsDecodingTests.swift in Sources */, 3AC9CEA8FC6D49A3EB7D7393 /* PublishedWorkflowCodableTests.swift in Sources */, 35E840CE2710E2EB00899AE2 /* MockManageSubscriptionsHelper.swift in Sources */, diff --git a/Sources/Paywalls/Components/PaywallWebViewComponent.swift b/Sources/Paywalls/Components/PaywallWebViewComponent.swift new file mode 100644 index 0000000000..8a61f4c3fa --- /dev/null +++ b/Sources/Paywalls/Components/PaywallWebViewComponent.swift @@ -0,0 +1,72 @@ +// +// 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 +// +// PaywallWebViewComponent.swift +// +// swiftlint:disable missing_docs + +import Foundation + +@_spi(Internal) public extension PaywallComponent { + + final class WebViewComponent: PaywallComponentBase { + + /// Wire `type` value, kept as a `String` because `web_view` is not yet a `ComponentType` case. + let type: String + public let id: String + public let name: String? + public let visible: Bool? + + public let protocolVersion: Int + + /// The static HTTPS URL of the web bundle entry point. + public let url: String + + public let size: Size + + public init( + id: String, + name: String? = nil, + visible: Bool? = nil, + protocolVersion: Int, + url: String, + size: Size = .init(width: .fill, height: .fit(nil)) + ) { + self.type = "web_view" + self.id = id + self.name = name + self.visible = visible + self.protocolVersion = protocolVersion + self.url = url + self.size = size + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(type) + hasher.combine(id) + hasher.combine(name) + hasher.combine(visible) + hasher.combine(protocolVersion) + hasher.combine(url) + hasher.combine(size) + } + + public static func == (lhs: WebViewComponent, rhs: WebViewComponent) -> Bool { + return lhs.type == rhs.type && + lhs.id == rhs.id && + lhs.name == rhs.name && + lhs.visible == rhs.visible && + lhs.protocolVersion == rhs.protocolVersion && + lhs.url == rhs.url && + lhs.size == rhs.size + } + + } + +} diff --git a/Tests/RevenueCatUITests/PaywallsV2/WebViewComponentTests.swift b/Tests/RevenueCatUITests/PaywallsV2/WebViewComponentTests.swift new file mode 100644 index 0000000000..713bfdbbba --- /dev/null +++ b/Tests/RevenueCatUITests/PaywallsV2/WebViewComponentTests.swift @@ -0,0 +1,205 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// + +@_spi(Internal) @testable import RevenueCat +import XCTest + +#if !os(tvOS) + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +final class WebViewComponentTests: TestCase { + + func testDecodesMinimalJSONAndIgnoresUnknownKeys() throws { + let minimal = try JSONDecoder.default.decode(PaywallComponent.WebViewComponent.self, from: Data(""" + { + "type": "web_view", + "id": "web", + "protocol_version": 1, + "url": "https://example.com", + "size": { "width": { "type": "fill" }, "height": { "type": "fit" } }, + "unknown": true + } + """.utf8)) + + XCTAssertEqual(minimal.id, "web") + XCTAssertNil(minimal.name) + XCTAssertNil(minimal.visible) + XCTAssertEqual(minimal.protocolVersion, 1) + XCTAssertEqual(minimal.size.width, .fill) + XCTAssertEqual(minimal.size.height, .fit(nil)) + XCTAssertEqual(minimal.url, "https://example.com") + XCTAssertEqual(minimal.type, "web_view") + } + + func testDecodesExplicitFields() throws { + let component = try JSONDecoder.default.decode(PaywallComponent.WebViewComponent.self, from: Data(""" + { + "type": "web_view", + "id": "web", + "name": "Survey", + "visible": false, + "protocol_version": 2, + "url": "https://example.com/index.html", + "size": { "width": { "type": "fixed", "value": 320 }, "height": { "type": "fit" } } + } + """.utf8)) + + XCTAssertEqual(component.id, "web") + XCTAssertEqual(component.name, "Survey") + XCTAssertEqual(component.visible, false) + XCTAssertEqual(component.protocolVersion, 2) + XCTAssertEqual(component.url, "https://example.com/index.html") + } + + func testIgnoresCapabilitiesDeclaredByTheSchema() throws { + // Isolation from external sources is expected from the server-provided CSP, so any + // schema-declared capabilities are decoded-and-ignored rather than failing to parse. + let component = try JSONDecoder.default.decode(PaywallComponent.WebViewComponent.self, from: Data(""" + { + "type": "web_view", + "id": "web", + "protocol_version": 1, + "url": "https://example.com/index.html", + "size": { "width": { "type": "fill" }, "height": { "type": "fit" } }, + "capabilities": { + "network_access": { "allowed_domains": ["api.segment.io"] }, + "camera": true, + "microphone": true, + "clipboard_write": true, + "clipboard_read": true, + "geolocation": true + } + } + """.utf8)) + + XCTAssertEqual( + component, + PaywallComponent.WebViewComponent(id: "web", protocolVersion: 1, url: "https://example.com/index.html") + ) + } + + func testDecodesTemplateURLVerbatim() throws { + // Template placeholders in the URL are resolved elsewhere; decoding must preserve them as-is. + let component = try JSONDecoder.default.decode(PaywallComponent.WebViewComponent.self, from: Data(""" + { + "type": "web_view", + "id": "web", + "protocol_version": 1, + "url": "https://example.com/{{ custom.animal }}.html", + "size": { "width": { "type": "fill" }, "height": { "type": "fit" } } + } + """.utf8)) + + XCTAssertEqual(component.url, "https://example.com/{{ custom.animal }}.html") + XCTAssertEqual( + component, + PaywallComponent.WebViewComponent( + id: "web", + protocolVersion: 1, + url: "https://example.com/{{ custom.animal }}.html" + ) + ) + } + + func testEncodeDecodeRoundTripUsesSnakeCaseWireKeys() throws { + let component = PaywallComponent.WebViewComponent( + id: "web", + name: "Survey", + visible: true, + protocolVersion: 2, + url: "https://example.com/index.html" + ) + + let data = try JSONEncoder.default.encode(component) + let json = try XCTUnwrap(String(data: data, encoding: .utf8)) + XCTAssertTrue(json.contains("\"protocol_version\"")) + XCTAssertTrue(json.contains("\"web_view\"")) + + let decoded = try JSONDecoder.default.decode(PaywallComponent.WebViewComponent.self, from: data) + XCTAssertEqual(decoded, component) + } + + func testDecodingWithoutURLThrows() { + XCTAssertThrowsError( + try JSONDecoder.default.decode(PaywallComponent.WebViewComponent.self, from: Data(""" + { + "type": "web_view", + "id": "web", + "protocol_version": 1, + "size": { "width": { "type": "fill" }, "height": { "type": "fit" } } + } + """.utf8)) + ) + } + + func testDecodingWithoutIDThrows() { + XCTAssertThrowsError( + try JSONDecoder.default.decode(PaywallComponent.WebViewComponent.self, from: Data(""" + { + "type": "web_view", + "protocol_version": 1, + "url": "https://example.com", + "size": { "width": { "type": "fill" }, "height": { "type": "fit" } } + } + """.utf8)) + ) + } + + func testDecodingWithoutProtocolVersionThrows() { + XCTAssertThrowsError( + try JSONDecoder.default.decode(PaywallComponent.WebViewComponent.self, from: Data(""" + { + "type": "web_view", + "id": "web", + "url": "https://example.com", + "size": { "width": { "type": "fill" }, "height": { "type": "fit" } } + } + """.utf8)) + ) + } + + func testDecodingWithoutSizeThrows() { + XCTAssertThrowsError( + try JSONDecoder.default.decode(PaywallComponent.WebViewComponent.self, from: Data(""" + { "type": "web_view", "id": "web", "protocol_version": 1, "url": "https://example.com" } + """.utf8)) + ) + } + + func testPaywallComponentTreatsWebViewAsUnknownAndUsesFallback() throws { + let decoded = try JSONDecoder.default.decode(PaywallComponent.self, from: Data(""" + { + "type": "web_view", + "url": "https://example.com", + "fallback": \(Self.fallbackStackJSON) + } + """.utf8)) + + guard case .stack = decoded else { + return XCTFail("web_view should be unknown to PaywallComponent and fall back to the stack") + } + } + + func testPaywallComponentWebViewWithoutFallbackThrows() { + XCTAssertThrowsError( + try JSONDecoder.default.decode(PaywallComponent.self, from: Data(""" + { "type": "web_view", "url": "https://example.com" } + """.utf8)) + ) + } + + private static let fallbackStackJSON = """ + { + "type": "stack", + "dimension": { "type": "vertical", "alignment": "center", "distribution": "start" }, + "size": { "width": { "type": "fill" }, "height": { "type": "fill" } }, + "padding": { "top": 0, "bottom": 0, "leading": 0, "trailing": 0 }, + "margin": { "top": 0, "bottom": 0, "leading": 0, "trailing": 0 }, + "components": [] + } + """ + +} + +#endif