* feat: mock `CustomerInfo` when in UI Preview mode and avoid caching it
* feat: return mock `CustomerInfo` when in UI Preview mode when requesting the cached `CustomerInfo`
* tests: add unit test for `CustomerInfo` mock in UI Preview mode
* fix linter complaints
* feat: use fix, hardcoded `appUserID` for UI preview mode
* test: add unit tests for user ID in UI preview mode
* fix linter's Trailing Whitespace Violations
* PR #4725 comments
* Change default appUserID for UI Preview mode.
* Add debug log regarding the use of UI Preview mode user ID.
### Description
This adds a new diagnostics event `http_request_performed` with some
useful information we would like to track about the http requests
performed to our servers.
### Description
This makes it so, if we detect the diagnostics file gets too big while
tracking an event, we just clear it and start over. Additionally, we add
an event tracking that this happened to the diagnostics file.
### Motivation
New function to get `Offering` by a placement identifier
### Description
New `currentOffering(forPlacement: String): Offering?` method on
`Offerings`
- Added demo button for this in purchase tester app
```swift
// Example usage
if let offering = offerings.currentOffering(forPlacement: "onboarding") {
showPaywall(offering)
}
```
#### Logic flow
- If a placement identifier explicitly exists within the
`placements.offeringIdsByPlacement` dictionary, the method attempts to
fetch the corresponding `Offering` without resorting to a fallback
- Example: when there is a `nil` / null value for a placement, we don't
want to use the fallback
- If the placement identifier does not exist, the method then attempts
to fetch a fallback offering defined by `placements.fallbackOfferingId`
#### New placements object in offerings response
```js
{
// other offerings stuff
"placements": {
"fallback_offering_id": "offering_a", // can be null
"offering_ids_by_placement": {
"onboarding_start": null,
"onboarding_end": "offering_b",
}
}
}
```
---------
Co-authored-by: Guido Torres <guido@revenuecat.com>
Co-authored-by: Josh Holtz <me@joshholtz.com>
Co-authored-by: RevenueCat Git Bot <72824662+RCGitBot@users.noreply.github.com>
I've moved `PaywallViewMode` from `RevenueCatUI` to `RevenueCat`. This
uses the same approach as #2645. This method is `public` so it can be
used from `RevenueCatUI`, but the documentation stays its purpose.
The behavior is covered with integration tests and a snapshot test.
Depends on https://github.com/RevenueCat/khepri/pull/6364.
These caches are important, especially for `RevenueCatUI`.
Without this fix, launching the app was doing this:
- Pre-warming cache
- Pre-warming cache a second time (to be fixed by a separate PR)
- Clearing cache
Which meant that the cache wasn't really warm when launching paywalls.
To fix that, this only clears the cache after receiving an actual
change.
I've also removed
`CustomerInfoManager.sendCachedCustomerInfoIfAvailable` because it
wasn't used.
There were some questions about these in #2841, and realized we had no
test coverage for them.
This also improves the documentation of `CustomerInfo.nonSubscriptions`
and `NonSubscriptionTransaction`.
The existing implementation was posting them in sequence, which meant
that it was always going to make N requests.
With this new implementation we can ensure that only one request is made
for all transactions.
### Description
Reported in https://github.com/RevenueCat/purchases-flutter/issues/738.
After https://github.com/RevenueCat/purchases-ios/pull/2654, we were
using the new Google Play identifiers that include the plan id in
expiration dates, but we didn't change the system that parses purchase
dates. Later on, when we map purchases, we iterate over the expiration
dates map, and since that id wasn't found in the purchase dates, it was
null, causing the issue in flutter.
This makes the purchase date calculation use the same algorithm that we
use for expiration dates to get the new google play product identifiers.
### Motivation
Addresses https://github.com/RevenueCat/purchases-flutter/issues/692
### Description
Shows the product id as `<product>:<base_plan>` if the subscription was
purchased from Google Play. This uses the `product_plan_identifier` that
is returned with the subscription.
This fixes the last edge-case for offline entitlements. Solves the
following scenario:
```swift
func testPurchaseWhileServerIsDownPostsReceiptWhenForegroundingApp() async throws {
let logger = TestLogHandler()
// 1. Purchase while server is down
self.serverDown()
try await self.purchaseMonthlyProduct()
logger.verifyMessageWasNotLogged("Finishing transaction")
// 2. Server is back
self.serverUp()
// 3. Request current CustomerInfo
let info1 = try await Purchases.shared.customerInfo()
try await self.verifyEntitlementWentThrough(info1)
// 4. Ensure transaction is finished
logger.verifyMessageWasLogged("Finishing transaction", level: .info)
// 5. Restart app
Purchases.shared.invalidateCustomerInfoCache()
await self.resetSingleton()
// 6. To ensure (with a clean cache) that the receipt was posted
let info2 = try await Purchases.shared.customerInfo()
try await self.verifyEntitlementWentThrough(info2)
}
```
This race condition happened when fetching `CustomerInfo` after we had
computed an offline `CustomerInfo` with a purchase: if the server is
still not aware of the purchase, then we lose that until some arbitrary
amount of time when `StoreKit` might decide to send the pending
transactions in the queue.
For that reason, instead of waiting, we proactively check now whenever
`CustomerInfoManager` fetches new info.
The refactors #2540 and #2542 where required to break a retain cycle.
Also thanks to #2534 we know for sure that there aren't any cycles.
Note that this is an iOS 15.0+ only feature. It would be useful to
better ensure consistency in older versions, but it's not strictly
required, as those versions don't support offline entitlements either.
### Changes:
- Created `PurchasedProductsFetcherType` to be able to mock and hold
fetchers, even on older devices
- Created `ProductEntitlementMappingFetcher` to abstract injecting
`ProductEntitlementMapping`
- Added logic to `CustomerInfoResponseHandler` to compute offline
`CustomerInfo` after a server error (see #2367). **This is used by all
`CustomerInfo` requests as well as `PostReceiptDataOperation`**.
- Wrote comprehensive `CustomerInfoResponseHandler` tests
### TODO:
- [x] Don't cache offline computed `CustomerInfo`: #2378
- [x] Integration tests: #2501.
### Changes:
- Added `Purchases.configureInCustomEntitlementsComputationMode`
- Added `Purchases.switchUser(to:)`
- Disabled parts of the SDK in this new mode
- New example app for testing `customEntitlementsComputation` mode
---------
Co-authored-by: NachoSoto <ignaciosoto90@gmail.com>
Fixes#2410 and
https://github.com/RevenueCat/react-native-purchases/issues/579.
This adds 2 previously failing tests that reproduce this issue: one
directly on `CustomerInfoManager` and another one utilizing
`PurchasesDelegate`.
These illustrate the issue: if the observer (through
`CustomerInfoManager.monitorChanges` or `PurchasesDelegate`) called a
method that ended up reading `CustomerInfoManager`'s data inside of the
observation, the SDK would deadlock.
This is because we were invoking the observers while inside the lock.
This was unnecessary, so this fixes the issue by ensuring that the
observers receive updates after the lock is released, using a new
`OperationDispatcher.dispatchAsyncOnMainThread`.
Fixes#2252, #2364, [SDKONCALL-214], [SDKONCALL-241].
See also #2078 and #2079.
### Background
#2078 solved a potential deadlock in `DeviceCache`. This could happen
when a background thread was in the middle of writing to `UserDefaults`
through `DeviceCache`, and at the same time, the main thread wanted to
read from it.
This is normally fine, except that if there are any `NotificationCenter`
observations on the `UserDefaults` used, the background thread would
then post that notification and wait for the main thread, leading to a
deadlock. What #2078 did was to use a different method to make sure that
the observation was received in the calling thread.
Additionally, #2079 avoided that observation whenever we used a
different `UserDefaults`.
### Problem
When the SDK changed to a custom `UserDefaults` (#2046), we made the
choice to only do this if there was no prior data.
However, if an app has prior RevenueCat data saved in
`UserDefaults.standard`, that instance is used.
This potential deadlock is possible for any app with prior data, that
also contains observations to `UserDefaults`, either manually through
`NotificationCenter`, or using something like `@AppStorage` with
`SwiftUI`.
### Solution
`DeviceCache.cachedAppUserID` is a very commonly used method. This PR
changes it to an in-memory cache, initialized to the `UserDefaults`
value.
By doing that, most code paths that rely on this value won't risk
deadlocking with `UserDefaults` writes that jump threads.
### Long term
I've filed SDK-3034 to fully migrate data from `UserDefaults.standard`
to avoid this problem altogether in the future.
[SDKONCALL-214]:
https://revenuecats.atlassian.net/browse/SDKONCALL-214?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ
[SDKONCALL-238]:
https://revenuecats.atlassian.net/browse/SDKONCALL-238?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ
[SDKONCALL-241]:
https://revenuecats.atlassian.net/browse/SDKONCALL-241?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ
### Purpose:
This is a prerequisite for #2288. The new grace period means that if we
didn't update the date from the cached responses, entitlements would
become stale and expired after 3 days.
### Changes:
- Added `HTTPResponseBody.copy(with newRequestDate:)` to be able to
modify responses from the header request date
- `CustomerInfo` implements this method to modify its request date
- Added `HTTPResponse.requestDate` to be able to keep track of the
request date from the server
- Added `VerificationResult.from(cache:response:)` to determine the most
restrictive verification result based on what's cached or checked from a
response
- Added `HTTPResponse.copy(with:)` to modify the verification result of
a response using the previous method
- `HTTPClient` now uses the most restrictive verification result
- `HTTPClient` updates request date from server responses or cached
responses (unless verification failed). This was the missing piece for
#2288.
### Tests:
- Verify that `ETagManager` does not use an ETag if verification was
previously not enabled
- Verify that `ETagManager` returns the request date from the server
when returning a cached response
- Verify that `HTTPClient` updates the request date from the server or
from a cached response
- Verify that `HTTPClient` does not update request date if verification
failed
- Tests for `HTTPResponse` request date parsing
- Test for `CustomerInfoResponseHandler` updating request date
- Tests for `CustomerInfo.copy(with newRequestDate:)`
### Other smaller changes:
- Moved `CustomerInfo.asData()` into `Encodable.asJSONEncodedData()`
- Renamed `requestTime` to `requestDate` everywhere for consistency
### Description
Deals with
[SDK-2817](https://linear.app/revenuecat/issue/SDK-2817/fix-issue-with-missing-subscriber-attributes-on-purchases-[ios])
This is the iOS counterpart to
https://github.com/RevenueCat/purchases-android/pull/809
There was an issue where subscriber attributes could be delayed to be
synced to the backend. This happened when setting a subscriber attribute
AFTER the `logIn` call but BEFORE the `logIn` operation was completed.
This issue happens because we start syncing subscriber attributes
immediately when calling `logIn`, but if we set any after that but
before the login is complete, they will be assigned to the old user id.
We would sync those the next time the app is backgrounded/foregrounded
but might be missed if there is a purchase before that happens. When
there is a purchase, we only sync the current user's unsynced
attributes, but not attributes of other users.
This PR copies all unsynced attributes from the old user id to the new
one upon a successful login. This way, the unsynced attributes will be
sent if the user tries to purchase.
Note that we only copy these attributes when the old user id was
anonymous. This is because there is a possible edge case of copying
attributes from a user to a different user if the sync request fails and
remains unsynced when changing between user ids.
Fixes [CSDK-629].
Depends on #2274 and #2267.
Entitlement verification is accomplished by preventing MiTM attacks
between the SDK and the RevenueCat server.
With verification enabled, the SDK ensures that the response created by
the server was not modified by a third-party, and the entitlements
received are exactly what was sent.
Entitlements are only verified if enabled using
`Configuration.Builder.with(entitlementVerificationMode:)`, which is
disabled by default.
### Changes:
- Added new `VerificationResult`, which replaces
`HTTPResponseVerificationResult`
- Renamed `Configuration.EntitlementVerificationLevel` to
`Configuration.EntitlementVerificationMode`
- `Configuration.with(entitlementVerificationMode:)` is now `public`
- Added `CustomerInfo.entitlementVerification`
- Added `EntitlementInfos.verification` and
`EntitlementInfo.verification`
- Added tests to cover serialization of these new values
- `CustomerInfoResponseHandler` copies the `ValidationResult` to
`CustomerInfo` (all operations that fetch `CustomerInfo` use this)
- Renamed "validation" to "verification" everywhere for consistency
- Finalized documentation
### TODO:
- [x] Figure out whether to use a separate type from
`HTTPResponseValidationResult` or rename that one
- [x] Figure out final API type and property names
- [x] Add tests for `CustomerInfo.copy(with:)`
- [x] Ensure validation result is encoded and decoded (for caching)
- [x] Add missing snapshots
[CSDK-629]:
https://revenuecats.atlassian.net/browse/CSDK-629?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ
The docs had this:
> Pass `nil` or an empty string if you want ``Purchases`` to generate
this for you.
But `nil` wasn't a valid input.
I noticed [this reported
issue](https://github.com/RevenueCat/purchases-ios/issues/2107#issuecomment-1332007011)
where the user was doing this:
```swift
.with(appUserID: userUID ?? "")
```
Which technically would have the same semantics, since we allow empty
and convert it to anonymous, but now this is more clear and it matches
the docstring.
Improved these 2 logs:
> 🍎‼️ Error fetching offerings - The operation couldn’t be completed. (RevenueCat.OfferingsManager.Error error 0.)
Underlying error: The operation couldn’t be completed. (RevenueCat.BackendError error 0.)
> ⚠️ Attempt to update CustomerInfo from network failed.
The operation couldn’t be completed. (RevenueCat.BackendError error 0.)
These 2 now will display the underlying `NetworkError` instead of the opaque "(RevenueCat.BackendError error 0.)".
This is the last change from what was started with #1437. That PR introduced a few private error types, but for the errors returned in the public `APIs` we were still passing around `Error`s everywhere, which didn't provide any compile-time guarantees.
### Goals:
- Ensure that public APIs *only* return `ErrorCode`s wrapped with the additional `userInfo`, and not accidentally return other types like `SKError`, `BackendError`, `ErrorCode` directly, etc.
- Guarantee type-safety when passing errors around within our internal implementations instead of assuming certain thrown errors are of a particular type.
### Changes:
- Created `PurchasesError` for wrapping an `ErrorCode` with the additional `userInfo`.
- Changed returned errors from `Error` to `NSError`. This is a change in the API, but the new type is a more specific `Error` so it shouldn't lead to any changes in client apps.
- Because `PurchasesError` isn't implicitly convertible to `NSError`, this ensures that we don't return those values directly, and instead use `PurchasesError.asPublicError`.
- Changed `ErrorCodeConvertible` to a now more precise `PurchasesErrorConvertible`.
Thanks to this new type-safety this PR fixes at least 3 bugs where we were returning private errors instead of `ErrorCode`s.
### Testing:
Thanks to #1871 we know that the returned errors are still convertible to `ErrorCode` so users can `switch` over them.