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

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.

- [x] `Offering` contains a `PaywallData`
- [x] `PaywallLocalizedConfiguration` contains no unrecognized variables
  • Loading branch information
NachoSoto committed Aug 15, 2023
1 parent 9956b2c commit 813ed1e
Show file tree
Hide file tree
Showing 11 changed files with 456 additions and 55 deletions.
131 changes: 131 additions & 0 deletions RevenueCatUI/Data/PaywallData+Validation.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
//
// 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, error: Offering.PaywallValidationError?)

enum PaywallValidationError: Equatable {

case missingPaywall
case invalidVariables(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 {
if let error = paywall.validate() {
// If there are any errors, create a default paywall
// with only the configured packages.
return (.createDefault(with: paywall.config.packages), error)
} else {
// Use the original paywall.
return (paywall, nil)
}
} else {
// If `Offering` has no paywall, create a default one with all available packages.
return (displayablePaywall: .createDefault(with: self.availablePackages),
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() -> Error? {
if let error = Self.validateLocalization(self.localizedConfiguration) {
return error
}

return nil
}

/// 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)
}

}

// 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 .invalidVariables(names):
return "Found unrecognized variables: \(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] }
}

}

private extension PaywallLocalizedConfiguration {

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

}
8 changes: 8 additions & 0 deletions RevenueCatUI/Data/TestData.swift
Original file line number Diff line number Diff line change
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
84 changes: 65 additions & 19 deletions RevenueCatUI/Data/Variables.swift
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,36 @@ enum VariableHandler {
}
}

fileprivate static func unrecognizedVariables(in set: Set<String>) -> Set<String> {
return Set(
set
.lazy
.filter { VariableHandler.provider(for: $0) == nil }
)
}

fileprivate typealias ValueProvider = (VariableDataProvider, Locale) -> String?

// swiftlint:disable:next cyclomatic_complexity
fileprivate static func provider(for variableName: String) -> ValueProvider? {
switch variableName {
case "app_name": return { (provider, _) in provider.applicationName }
case "price": return { (provider, _) in provider.localizedPrice }
case "price_per_period": return { $0.localizedPricePerPeriod($1) }
case "total_price_and_per_month": return { $0.localizedPriceAndPerMonth($1) }
case "product_name": return { (provider, _) in provider.productName }
case "sub_period": return { $0.periodName($1) }
case "sub_price_per_month": return { (provider, _) in provider.localizedPricePerMonth }
case "sub_duration": return { $0.subscriptionDuration($1) }
case "sub_offer_duration": return { $0.introductoryOfferDuration($1) }
case "sub_offer_price": return { (provider, _) in provider.localizedIntroductoryOfferPrice }

default:
Logger.warning(Strings.unrecognized_variable_name(variableName: variableName))
return nil
}
}

}

@available(iOS 15.0, macOS 12.0, tvOS 15.0, *)
Expand All @@ -55,29 +85,21 @@ extension String {
return VariableHandler.processVariables(in: self, with: provider, locale: locale)
}

func unrecognizedVariables() -> Set<String> {
if #available(iOS 16.0, macOS 13.0, tvOS 16.0, *) {
return VariableHandlerIOS16.unrecognizedVariables(in: self)
} else {
return VariableHandlerIOS15.unrecognizedVariables(in: self)
}
}

}

@available(iOS 15.0, macOS 12.0, tvOS 15.0, *)
private extension VariableDataProvider {

// swiftlint:disable:next cyclomatic_complexity
func value(for variableName: String, locale: Locale) -> String {
switch variableName {
case "app_name": return self.applicationName
case "price": return self.localizedPrice
case "price_per_period": return self.localizedPricePerPeriod(locale)
case "total_price_and_per_month": return self.localizedPriceAndPerMonth(locale)
case "product_name": return self.productName
case "sub_period": return self.periodName(locale)
case "sub_price_per_month": return self.localizedPricePerMonth
case "sub_duration": return self.subscriptionDuration(locale) ?? ""
case "sub_offer_duration": return self.introductoryOfferDuration(locale) ?? ""
case "sub_offer_price": return self.localizedIntroductoryOfferPrice ?? ""

default:
Logger.warning(Strings.unrecognized_variable_name(variableName: variableName))
return ""
}
VariableHandler.provider(for: variableName)?(self, locale) ?? ""
}

}
Expand All @@ -103,6 +125,12 @@ private enum VariableHandlerIOS16 {
return replacedString
}

static func unrecognizedVariables(in string: String) -> Set<String> {
return VariableHandler.unrecognizedVariables(
in: Set(Self.extractVariables(from: string).map(\.variable))
)
}

private static func extractVariables(from expression: String) -> [VariableMatch] {
return expression.matches(of: Self.regex).map { match in
let (_, variable) = match.output
Expand Down Expand Up @@ -140,8 +168,7 @@ private enum VariableHandlerIOS15 {
locale: Locale = .current
) -> String {
var replacedString = string
let range = NSRange(string.startIndex..., in: string)
let matches = Self.regex.matches(in: string, options: [], range: range)
let matches = Self.regex.matches(in: string, options: [], range: string.range)

for match in matches.reversed() {
let variableNameRange = match.range(at: 1)
Expand All @@ -162,10 +189,29 @@ private enum VariableHandlerIOS15 {
return replacedString
}

static func unrecognizedVariables(in string: String) -> Set<String> {
let matches = Self.regex.matches(in: string, options: [], range: string.range)

var variables: Set<String> = []
for match in matches {
if let variableNameRange = Range(match.range(at: 1), in: string) {
variables.insert(String(string[variableNameRange]))
}
}

return VariableHandler.unrecognizedVariables(in: variables)
}

private static let pattern = "{{ }}"
// Fix-me: this can be implemented using the new Regex from Swift.
// This regex is known at compile time and tested:
// swiftlint:disable:next force_try
private static let regex = try! NSRegularExpression(pattern: "\\{\\{ (\\w+) \\}\\}", options: [])

}

private extension String {

var range: NSRange { .init(self.startIndex..., in: self) }

}
39 changes: 16 additions & 23 deletions RevenueCatUI/PaywallView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -121,34 +121,27 @@ public struct PaywallView: View {
checker: TrialOrIntroEligibilityChecker,
purchaseHandler: PurchaseHandler
) -> some View {
if let paywall = offering.paywall {
LoadedOfferingPaywallView(
offering: offering,
paywall: paywall,
mode: self.mode,
fonts: fonts,
introEligibility: checker,
purchaseHandler: purchaseHandler
)
} else {
let (paywall, error) = offering.validatedPaywall()

let paywallView = LoadedOfferingPaywallView(
offering: offering,
paywall: paywall,
mode: self.mode,
fonts: fonts,
introEligibility: checker,
purchaseHandler: purchaseHandler
)

if let error {
DebugErrorView(
"Offering '\(offering.identifier)' has no configured paywall, or it has invalid data.\n" +
"\(error.description)\n" +
"You can fix this by editing the paywall in the RevenueCat dashboard.\n" +
"The displayed paywall contains default configuration.\n" +
"This error will be hidden in production.",
releaseBehavior: .replacement(
AnyView(
LoadedOfferingPaywallView(
offering: offering,
paywall: .createDefault(with: offering.availablePackages),
mode: self.mode,
fonts: fonts,
introEligibility: checker,
purchaseHandler: purchaseHandler
)
)
)
releaseBehavior: .replacement(AnyView(paywallView))
)
} else {
paywallView
}
}

Expand Down
2 changes: 1 addition & 1 deletion Sources/Paywalls/PaywallData.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ public struct PaywallData {
public var assetBaseURL: URL

@EnsureNonEmptyCollectionDecodable
internal var localization: [String: LocalizedConfiguration]
internal private(set) var localization: [String: LocalizedConfiguration]

}

Expand Down
Loading

0 comments on commit 813ed1e

Please sign in to comment.