* feat: add support for presenting draft paywalls under `@_spi(Internal)` attribute
* fix: initialize `useDraftPaywall` property of `PaywallView`
* feat: have `PaywallView` actually show draft paywall when `useDraftPaywall` is `true`
* remove lint warning
* silence linter warning
* tests: add unit tests for `paywallComponents` and `draftPaywallComponents` in `OfferingsResponse`
* tests: move test offerings with v2 paywalls into a new file not to affect existing decode + encode test
* remove unnecessary property wrapper
* silence lint warning
⚠️ Waiting for #4188 to be merged first (because needs all the tests to
pass)
## Motivation
Fix cases where paywall was showing multiple languages at once and
random languages.
## Description
👉 For a demo of the problem and solution, look in Slack in the paywalls
or SDK channel for the latest Loom that I posted
### Flow of paywall localization logic (since it can be confusing)
1.
[PaywallView](https://github.com/RevenueCat/purchases-ios/pull/4158/files#diff-ea75fe7280803e15b975d702a16c6851ffc2bbed01b7b196bf0fb80d75b4b332)
starts with `Locale.current` but then calls
`offering.validatedPaywall()` which will return the new
`displayedLocale: Locale` variable
2.
[PaywallData+Validation](https://github.com/RevenueCat/purchases-ios/pull/4158/files#diff-452790cf5287a44f75a4b2260254db2281a066faeea2b3e441a007ec60522b6b)
returns `displayedLocale` if its a valid paywall, otherwise it return
`Locale.current` for fallback paywall
3.
[PaywallData+Localization](https://github.com/RevenueCat/purchases-ios/pull/4158/files#diff-e620baead94f7acad56d931c485d7691c4f909be3fe625e104528a2702cca2a0)
has a lot of the new logic
- New `locale: Locale` computer property that it determines the locale
from the `localizedConfiguration` or `localizedConfigurationsByTier`
- New ` defaultLocalizedConfiguration` that uses the developer set
`defaultLocale` (from `PaywallData`) to find the default localization
- Updated `localizedConfiguration<Value>()` to take a new
`defaultLocalization` to put in its priority (and log it if its used).
If for some reason that's not found, it will use the legacy
`fallbackLocalization` (which is the first localization it finds or
english)
4. After all of that, the `displayedLocale` is passed into the actually
SwiftUI `LoadedOfferingPaywallView` to show this locale everywhere
### Problem 1 - Inconsistent languages displayed at once
The user defined localizations and the pre-localized content in the SDK
(like links in the footer and package duration names) could should
different localizations. The choice of which locale was happening in two
different spots.
Since the SDK has all possible localizations that the dashboard offers,
the locale SDK displays from the dashboard will be chosen for the
pre-localized content.
### Problem 2 - Showing random languages if not supported
We decided early on to not use a `default_locale` but that has hurt us.
The solution to use was use `localizations.first` to show the first
locale in the list and the intention that this was the first locale the
user did in the paywall. That was not the case, however. The order of
the localizations list was indeterministic so it should should a random
language (even to the point where different random languages would show
the the same user).
We are now introducing a `default_locale` that the developer can set in
the paywall. If the locale the user is requesting is not translated by
the developer, it will use the `default_locale`
### Problem 3 - Footer not using correct localization from the bundle
There were times where some parts of the footer itself (mainly the
restore button) would be a different language than the terms of service
and privacy policy buttons. This was due to a code issue where we were
using `Bundle.module`. Instead, we needed to specify a localization
bundle specifically that we wanted to use.
Josh kindly [fixed a
problem](https://github.com/RevenueCat/purchases-ios/pull/4035) while I
was away where the purchase handler would get clobbered when the paywall
was re-rendered. This PR restores avoiding having one struct owned by
two @StateObjects at the same time.
### Motivation
Fixes#4034
`PurchaseHandler` was being cleared/reset when a paywall was being
rendered (ex: if an overlay was applied to it)
### Description
- Restores `PurchaseHandler` back to being a `@StateObject` from #3187
- `checkForConfigurationConsistency()` has a few changes
- Takes a `PurchaseHandler` as a parameter so it doesn't try to access
the `StateObject`
- This was fixes the purple Xcode warning of being access wrongly by it
being a `StateObject` again
- Removes `mutating` keyword and returns an `NSError?` which it now sets
in the constructor
### Demo
#### Using RevenueCat
https://github.com/RevenueCat/purchases-ios/assets/401294/7e6430ca-6243-49ad-b7c0-8a90d90894f8
#### Using MyApp
https://github.com/RevenueCat/purchases-ios/assets/401294/829f12c3-a11a-4952-883c-7ae7addd9c1d
## tl;dr
PaywallView has a new constructor that takes `performPurchase` and
`performRestore` blocks, which are called to preform purchasing/restore
directly by the customer's app when the purchase/restore buttons are
tapped on the paywall. This makes it possible to use RC Paywalls when
`Purchases` has been configured `.with(purchasesAreCompletedBy:
.myApp)`.
Example usage:
```swift
PaywallView(performPurchase: {
var userCancelled = false
var error: Error?
// use StoreKit to perform purchase
return (userCancelled: userCancelled, error: error)
}, performRestore: {
var success = false
var error: Error?
// use StoreKit to perform restore
return (success: success, error: error)
})
```
## Description
When a `PaywallView` is constructed, a new `PurchaseHandler` is created.
The `PurchaseHandler` (an internal RevenueCatUI class) is owned by the
`PaywallView`, and it is responsible for executing new purchases and
restores.
When a `PaywallView` is constructed **without** `performPurchase` and
`performRestore` blocks, the `PaywallView` creates a `PurchaseHandler`
capable of preforming purchases using RevenueCat. When a `PaywallView`
is constructed with `performPurchase` and `performRestore` blocks, it
can also make purchases using the customer-supplied closures.
The `PurchaseHandler` is invoked when the user taps the
`PurchaseButton`, calling `purchaseHandler.purchase(package:
self.selectedPackage.content)`, which branches to either the internal or
external purchase code, as defined by `purchasesAreCompletedBy`:
```swift
@MainActor
func purchase(package: Package) async throws {
switch self.purchases.purchasesAreCompletedBy {
case .revenueCat:
try await performPurchase(package: package)
case .myApp:
try await performExternalPurchaseLogic(package: package)
}
}
```
Purchase and Restore blocks can also be assigned for paywall footers:
```swift
MyAppDefinedPaywall()
.paywallFooter(myAppPurchaseLogic: MyAppPurchaseLogic(performPurchase: { packageToPurchase in
var userCancelled = false
var error: Error?
// use StoreKit to perform purchase
return (userCancelled: userCancelled, error: error)
}, performRestore: {
var success = false
var error: Error?
// use StoreKit to perform restore
return (success: success, error: error)
}))
```
and via `.presentPaywallIfNeeded`:
```swift
MyAppDefinedPaywal()
.presentPaywallIfNeeded(requiredEntitlementIdentifier: "test",
myAppPurchaseLogic: MyAppPurchaseLogic(performPurchase: {
packageToPurchase in
return (userCancelled: false, error: nil)
}, performRestore: {
return (success: true, error: nil)
}))
```
## Notes
### Testing
A lot needs to be mocked, the purchase/restore blocks that are passed in need to be assigned directly to the `PurchaseHandler`, which is then passed in as part of a `PaywallConfiguration`, both of which are normally constructed internally in the PaywallView constructor.
```swift
func testHandleExternalRestoreWithPurchaaseHandlers() throws {
var completed = false
var customRestoreCodeExecuted = false
let purchasHandler = Self.externalPurchaseHandler { _ in
return (userCancelled: true, error: nil)
} performRestore: {
customRestoreCodeExecuted = true
return (success: true, error: nil)
}
let config = PaywallViewConfiguration(purchaseHandler: purchasHandler)
try PaywallView(configuration: config).addToHierarchy()
Task {
_ = try await purchasHandler.restorePurchases()
completed = true
}
expect(completed).toEventually(beTrue())
expect(customRestoreCodeExecuted) == true
}
```
### Error Handling
For the external code blocks to be called, `purchasesAreCompletedBy` needs to be set to `.myApp` AND the blocks need to be defined. So we need to handle cases when these are not consistent:
1. If someone configures purchasesAreCompletedBy to `.myApp`, and then displays a PaywallView **without** purchase/restore handlers, the SDK will:
* Log an error when the PaywallView is constructed
* When the PaywallView is displayed, replace it with a big red banner when in debug mode, fatalError() in release mode.
* If you purchase directly via the purchase handler (which is an internal class but this is done for testing), it will throw.
2. If someone configures purchases to use `.revenueCat`, and then displays a PaywallView **with** purchase/restore handler, the SDK will:
* Log a warning when the PaywallView is constructed
Rationale:
In case 1, there could be no (acceptable) way to make a purchase, so this is very bad and it needs to be dealt with.
In case 2, they've over-specified the paywall, and while this might be confusing (why isn't my code being called??), it's not as problematic, and we want to make it easy for people to switch from using their purchase logic to our purchase logic.
These checks are made in PaywallView.swift, method `checkForConfigurationConsitency()`.
### UIKit
Not yet supported, will add in a follow-up PR, unlikely to be complicated.
### Paywall Footer and Paywall If Needed
The `performPurchase` and `performRestore` parameters for the paywall footer are contained in a struct rather than as loose parameters of closure types, because doing the latter would create a very poor experience with regards to code completion, where Xcode will always offer complete your code as a trailing closure, but will always use the first closure where the function signature matches.
The following illustrates the problem of using loose closure parameters:
The trailing closure that Xcode auto-creates here does _not_ get called for `performPurchase`, but rather `purchaseStarted`, because it also has a `package` as its input parameter, and comes first in the parameter list 😱.
https://github.com/RevenueCat/purchases-ios/assets/109382862/1f809e7c-b661-4844-89e3-fd846a029531
This is the problematic function signature:
```swift
public func paywallFooter(
offering: Offering,
condensed: Bool = false,
fonts: PaywallFontProvider = DefaultPaywallFontProvider(),
purchaseStarted: PurchaseOfPackageStartedHandler? = nil,
purchaseCompleted: PurchaseOrRestoreCompletedHandler? = nil,
purchaseCancelled: PurchaseCancelledHandler? = nil,
restoreStarted: RestoreStartedHandler? = nil,
restoreCompleted: PurchaseOrRestoreCompletedHandler? = nil,
purchaseFailure: PurchaseFailureHandler? = nil,
restoreFailure: PurchaseFailureHandler? = nil,
performPurchase: PerformPurchase? = nil,
performRestore: PerformRestore? = nil
) -> some View
```
To fix this, get rid of the two new parameters:
```swift
performPurchase: PerformPurchase? = nil,
performRestore: PerformRestore? = nil
```
and embed them in a struct:
```swift
public struct MyAppPurchaseLogic {
public let performPurchase: PerformPurchase
public let performRestore: PerformRestore
public init(performPurchase: @escaping PerformPurchase, performRestore: @escaping PerformRestore) {
self.performPurchase = performPurchase
self.performRestore = performRestore
}
}
```
which we use as a last parameter:
```swift
myAppPurchaseLogic: MyAppPurchaseLogic? = nil
```
Now the code completes as so:
https://github.com/RevenueCat/purchases-ios/assets/109382862/332ac42d-ccba-42bc-bfc3-702d7870c99c
The `MyAppPurchaseLogic` doesn't complete in perfectly, but once you
initialize one of those it goes nicely the rest of the way, including
the error messages that instruct you on what you need to return.
---------
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Josh Holtz <me@joshholtz.com>
Co-authored-by: Cesar de la Vega <cesarvegaro@gmail.com>
Co-authored-by: RevenueCat Git Bot <72824662+RCGitBot@users.noreply.github.com>
Co-authored-by: Toni Rico <toni.rico.diez@revenuecat.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Will Taylor <wtaylor151@gmail.com>
Co-authored-by: Andy Boedo <andresboedo@gmail.com>
This PR allows the Paywalls Tester app to access some items internal to
purchases-ios by temporarily marking them public during complication.
Items that need to be public have been annotated with
`\\@PublicForExternalTesting`.
The Paywalls Tester project runs a script, Preprocessor.sh, as a
pre-action to add `public` to all classes/structs/enums/functions/inits
that have been annotated.
After compilation it runs a second script, Postprocessor.sh, as a
post-action to undo these changes so that they don't accidentally get
checked in.
The first script is run as a scheme pre-action rather than as a run
script build phase, because when run as a build phase the changes to the
files aren't picked up until the next compilation attempt.
<img width="669" alt="image"
src="https://github.com/RevenueCat/purchases-ios/assets/109382862/0b0bacdb-79de-441d-8d61-2a5dd03b1b53">
It also changes the archive step to be built with a release
configuration, and removes the use of `@testable import` for debug
builds.
resolves PWL-459