Skip to content

Commit

Permalink
Paywalls: validate PaywallData to ensure displayed data is always…
Browse files Browse the repository at this point in the history
… correct (#3019)

See
https://docs.google.com/document/d/1oLZR77apAjZf04hDzlrJpPaFV0xhuQ1-hApsNBohhfA/edit

This simplifies `PaywallView`: the validated `PaywallData` is what's
always displayed, and a `DebugErrorView` containing an error description
will potentially explain why a default paywall is being displayed.

### Validations:

- [x] `Offering` contains a `PaywallData`
- [x] `PaywallLocalizedConfiguration` contains no unrecognized variables
- [x] `PaywallIcon`s are all recognized
- [x] `PaywallTemplate` is recognized
  • Loading branch information
NachoSoto committed Sep 7, 2023
1 parent bd77769 commit 6918e8c
Show file tree
Hide file tree
Showing 27 changed files with 794 additions and 113 deletions.
4 changes: 0 additions & 4 deletions RevenueCat.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -266,7 +266,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 @@ -1007,7 +1006,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 @@ -2265,7 +2263,6 @@
4FBBD4E52A620573001CBA21 /* PaywallColor.swift */,
4F87610E2A5C9E490006FA14 /* PaywallData.swift */,
4F062D312A85A11600A8A613 /* PaywallData+Localization.swift */,
4F87612B2A5CAB980006FA14 /* PaywallTemplate.swift */,
4F6ABC772A81595900250E63 /* PaywallCacheWarming.swift */,
4F89A55C2A6ABADF008A411E /* PaywallViewMode.swift */,
);
Expand Down Expand Up @@ -3430,7 +3427,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))
}

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?>> {
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

0 comments on commit 6918e8c

Please sign in to comment.