Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Paywalls: validate PaywallData to ensure displayed data is always correct #3019

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
4 changes: 0 additions & 4 deletions RevenueCat.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -262,7 +262,6 @@
4F83F6BB2A5DB80B003F90A5 /* OSVersionEquivalent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57DE80AD28075D77008D6C6F /* OSVersionEquivalent.swift */; };
4F8452682A5756CC00084550 /* HTTPRequestBody+Signing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F8452672A5756CC00084550 /* HTTPRequestBody+Signing.swift */; };
4F87610F2A5C9E490006FA14 /* PaywallData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F87610E2A5C9E490006FA14 /* PaywallData.swift */; };
4F87612C2A5CAB980006FA14 /* PaywallTemplate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F87612B2A5CAB980006FA14 /* PaywallTemplate.swift */; };
4F8929192A65EF3000A91EA2 /* EnsureNonEmptyCollectionDecodable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F8929182A65EF3000A91EA2 /* EnsureNonEmptyCollectionDecodable.swift */; };
4F89A55D2A6ABADF008A411E /* PaywallViewMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F89A55C2A6ABADF008A411E /* PaywallViewMode.swift */; };
4F8A58172A16EE3500EF97AD /* MockOfflineCustomerInfoCreator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F8A58162A16EE3500EF97AD /* MockOfflineCustomerInfoCreator.swift */; };
Expand Down Expand Up @@ -1002,7 +1001,6 @@
4F8038322A1EA7C300D21039 /* TransactionPoster.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransactionPoster.swift; sourceTree = "<group>"; };
4F8452672A5756CC00084550 /* HTTPRequestBody+Signing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HTTPRequestBody+Signing.swift"; sourceTree = "<group>"; };
4F87610E2A5C9E490006FA14 /* PaywallData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallData.swift; sourceTree = "<group>"; };
4F87612B2A5CAB980006FA14 /* PaywallTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallTemplate.swift; sourceTree = "<group>"; };
4F8929182A65EF3000A91EA2 /* EnsureNonEmptyCollectionDecodable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnsureNonEmptyCollectionDecodable.swift; sourceTree = "<group>"; };
4F89A55C2A6ABADF008A411E /* PaywallViewMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallViewMode.swift; sourceTree = "<group>"; };
4F8A58162A16EE3500EF97AD /* MockOfflineCustomerInfoCreator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockOfflineCustomerInfoCreator.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -2262,7 +2260,6 @@
4FBBD4E52A620573001CBA21 /* PaywallColor.swift */,
4F87610E2A5C9E490006FA14 /* PaywallData.swift */,
4F062D312A85A11600A8A613 /* PaywallData+Localization.swift */,
4F87612B2A5CAB980006FA14 /* PaywallTemplate.swift */,
4F6ABC772A81595900250E63 /* PaywallCacheWarming.swift */,
4F89A55C2A6ABADF008A411E /* PaywallViewMode.swift */,
);
Expand Down Expand Up @@ -3424,7 +3421,6 @@
2DDF41AD24F6F37C005BC22D /* ASN1ObjectIdentifier.swift in Sources */,
35549323269E298B005F9AE9 /* OfferingsFactory.swift in Sources */,
57536A28278522B400E2AE7F /* SK2StoreTransaction.swift in Sources */,
4F87612C2A5CAB980006FA14 /* PaywallTemplate.swift in Sources */,
2D9C7BB326D838FC006838BE /* UIApplication+RCExtensions.swift in Sources */,
F56E2E7727622B5E009FED5B /* TransactionsManager.swift in Sources */,
B34605CC279A6E380031CA74 /* LogInOperation.swift in Sources */,
Expand Down
179 changes: 179 additions & 0 deletions RevenueCatUI/Data/PaywallData+Validation.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
//
// PaywallData+Validation.swift
//
//
// Created by Nacho Soto on 8/15/23.
//

import RevenueCat

// MARK: - Errors

@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
extension Offering {

typealias ValidationResult = (displayablePaywall: PaywallData,
template: PaywallTemplate,
error: Offering.PaywallValidationError?)

enum PaywallValidationError: Swift.Error, Equatable {

case missingPaywall
case invalidTemplate(String)
case invalidVariables(Set<String>)
case invalidIcons(Set<String>)

}

}

// MARK: - Offering validation

@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
extension Offering {

/// - Returns: a validated paywall suitable to be displayed, and any associated error.
func validatedPaywall() -> ValidationResult {
if let paywall = self.paywall {
switch paywall.validate() {
case let .success(template):
return (paywall, template, nil)

case let .failure(error):
// If there are any errors, create a default paywall
// with only the configured packages.
return (.createDefault(with: paywall.config.packages),
PaywallData.defaultTemplate,
error)
}
} else {
// If `Offering` has no paywall, create a default one with all available packages.
return (displayablePaywall: .createDefault(with: self.availablePackages),
PaywallData.defaultTemplate,
error: .missingPaywall)
}
}

}

// MARK: - PaywallData validation

@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
private extension PaywallData {

typealias Error = Offering.PaywallValidationError

/// - Returns: `nil` if there are no validation errors.
func validate() -> Result<PaywallTemplate, Error> {
if let error = Self.validateLocalization(self.localizedConfiguration) {
return .failure(error)
}

guard let template = PaywallTemplate(rawValue: self.templateName) else {
return .failure(.invalidTemplate(self.templateName))
}

let invalidIcons = self.localizedConfiguration.validateIcons()
guard invalidIcons.isEmpty else {
return .failure(.invalidIcons(invalidIcons))
}

NachoSoto marked this conversation as resolved.
Show resolved Hide resolved
return .success(template)
}

/// Validates that all strings inside of `LocalizedConfiguration` contain no unrecognized variables.
private static func validateLocalization(_ localization: LocalizedConfiguration) -> Error? {
let unrecognizedVariables = Set(
localization
.allValues
.lazy
.compactMap { $0.unrecognizedVariables() }
.joined()
)

return unrecognizedVariables.isEmpty
? nil
: .invalidVariables(unrecognizedVariables)
}

}

private extension PaywallData.LocalizedConfiguration {

/// - Returns: the set of invalid icons
func validateIcons() -> Set<String> {
return Set(self.features.compactMap { $0.validateIcon() })
}

}

private extension PaywallData.LocalizedConfiguration.Feature {

/// - Returns: the icon ID if it's not recognized
func validateIcon() -> String? {
guard let iconID = self.iconID else { return nil }

return PaywallIcon(rawValue: iconID) == nil
? iconID
: nil
}

}

// MARK: - Errors

@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
extension Offering.PaywallValidationError: CustomStringConvertible {

var description: String {
switch self {
case .missingPaywall:
return "Offering has no configured paywall."

case let .invalidTemplate(name):
return "Template not recognized: \(name)."

case let .invalidVariables(names):
return "Found unrecognized variables: \(names.joined(separator: ", "))."

case let .invalidIcons(names):
return "Found unrecognized icons: \(names.joined(separator: ", "))."
}
}

}

// MARK: - PaywallLocalizedConfiguration

private extension PaywallLocalizedConfiguration {

/// The set of properties inside a `PaywallLocalizedConfiguration`.
static var allProperties: Set<KeyPath<Self, String?>> {
Copy link
Contributor Author

@NachoSoto NachoSoto Aug 15, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Swift compile-time magic FTW

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are some days where I don't feel like I know any Swift 😅

return [
\.optionalTitle,
\.subtitle,
\.optionalCallToAction,
\.callToActionWithIntroOffer,
\.offerDetails,
\.offerDetailsWithIntroOffer,
\.offerName
]
}

var allValues: [String] {
return Self
.allProperties
.compactMap { self[keyPath: $0] }
+ self.features.flatMap {
[$0.title, $0.content].compactMap { $0 }
}
}

}

private extension PaywallLocalizedConfiguration {

var optionalTitle: String? { return self.title }
var optionalCallToAction: String? { self.callToAction }

}
Original file line number Diff line number Diff line change
Expand Up @@ -14,19 +14,14 @@
import Foundation

/// The type of template used to display a paywall.
public enum PaywallTemplate: String {
internal enum PaywallTemplate: String {

// swiftlint:disable missing_docs
case template1 = "1"
case template2 = "2"
case template3 = "3"
case template4 = "4"

// swiftlint:enable missing_docs

}

extension PaywallTemplate: Codable {}
extension PaywallTemplate: Sendable {}
extension PaywallTemplate: Equatable {}
extension PaywallTemplate: CaseIterable {}
18 changes: 13 additions & 5 deletions RevenueCatUI/Data/TestData.swift
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ internal enum TestData {
]

static let paywallWithIntroOffer = PaywallData(
template: .template1,
templateName: PaywallTemplate.template1.rawValue,
config: .init(
packages: [PackageType.monthly.identifier],
images: Self.images,
Expand All @@ -162,7 +162,7 @@ internal enum TestData {
assetBaseURL: Self.paywallAssetBaseURL
)
static let paywallWithNoIntroOffer = PaywallData(
template: .template1,
templateName: PaywallTemplate.template1.rawValue,
config: .init(
packages: [PackageType.annual.identifier],
images: Self.images,
Expand Down Expand Up @@ -193,7 +193,7 @@ internal enum TestData {
serverDescription: "Offering",
metadata: [:],
paywall: .init(
template: .template2,
templateName: PaywallTemplate.template2.rawValue,
config: .init(
packages: [PackageType.annual.identifier, PackageType.monthly.identifier],
images: Self.images,
Expand Down Expand Up @@ -231,7 +231,7 @@ internal enum TestData {
serverDescription: "Offering",
metadata: [:],
paywall: .init(
template: .template3,
templateName: PaywallTemplate.template3.rawValue,
config: .init(
packages: [PackageType.annual.identifier],
images: Self.images,
Expand Down Expand Up @@ -288,7 +288,7 @@ internal enum TestData {
serverDescription: "Offering",
metadata: [:],
paywall: .init(
template: .template4,
templateName: PaywallTemplate.template4.rawValue,
config: .init(
packages: [PackageType.monthly.identifier,
PackageType.sixMonth.identifier,
Expand Down Expand Up @@ -323,6 +323,14 @@ internal enum TestData {
TestData.annualPackage]
)

static let offeringWithNoPaywall = Offering(
identifier: Self.offeringIdentifier,
serverDescription: "Offering",
metadata: [:],
paywall: nil,
availablePackages: Self.packages
)

static let lightColors: PaywallData.Configuration.Colors = .init(
background: "#FFFFFF",
text1: "#000000",
Expand Down
Loading