* feat: add missing properties to `apple_purchase_attempt` diagnostics event
* tests: add + edit unit tests for new diagnostics parameters for SK1 purchase attempt
* fix implementation of sk2 product purchase to catch promotional offer signature error again
* tests: edit unit tests for new diagnostics parameters for SK2 purchase attempt
* fix failing test
* refactor: separate error handling in purchase SK2 product method into its own method
* address PR #4835 comments
* lint
* 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.
* Introduce PurchaseParams to allow passing extra configuration info when making a purchase
* simplify naming
* fallback to old method when flag is not enabled
* Add $(inherited) to the test targets so they pick up compiler flags from Local.xcconfig
* Include product/package inside PurchaseParams
* fix tests
* add compiler flag check
* update purchase tester
* fix docs
* add tests
* Update Sources/Networking/Operations/PostReceiptDataOperation.swift
Co-authored-by: Toni Rico <toni.rico.diez@revenuecat.com>
---------
Co-authored-by: Toni Rico <toni.rico.diez@revenuecat.com>
⚠️ 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.
We had a report of a developer that is seeing that users (who are using
iOS 18 beta) are reporting app crashes on launch with 55 unique devices
so far. They are using using iOS SDK 4.17.7.
The line that is crashing is:
```
client = clientClass.perform(#selector(FakeAfficheClient.sharedClient)).takeUnretainedValue()
```
We just couldn’t have a single variable named `adClient` without an app
getting rejected, even if it wasn’t using it at all. We built the
`AfficheClient` so prevent app rejections.
It turns out Apple has removed documentation for `AdClient`, and by
looking into
https://developer.apple.com/documentation/iad/setting-up-apple-search-ads-attribution#overview
> After February 7, 2023, all requests made to the Apple Search Ads iAd
Attribution API will return with a value of "iad-attribution" = false,
or errors. See ADClient/requestAttributionDetails(_:). Use the
AdServices framework for current attribution integration with the Apple
Search Ads Campaign Management API for devices using iOS 14.3 and later.
Attribution isn't available for downloads and redownloads from devices
using iOS 14.2 or earlier.
So this wasn't even working anyways. I removed everything related but
left the public API in place to not break anyone.
### Description
This will make sure that we delete the diagnostics file if there is an
issue syncing it over multiple retries (note these retries only happen
during SDK configuration).
### 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.
Create `DiagnosticsTracker` and adds it to the `Purchases`
initialization. I don't expose the `diagnosticsEnabled` yet
---------
Co-authored-by: Toni Rico <antonio.rico.diez@revenuecat.com>
### Motivation
Adding new method to manually sync attributes and fetch fresh offerings
### Description
- New `Purchases.shared.syncAttributesAndOfferingsIfNeeded()` method
- Syncs subscriber attributes and then fetches fresh offerings from
network
- This is designed to be a blocking call since developer will want fresh
offerings after syncing subscriber attributes
- The method is rate limited to 5 calls per minute (new `RateLimiter`
class()
---------
Co-authored-by: Josh Holtz <me@joshholtz.com>
### 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>
This was already approved and merged into `5.0-dev` but bringing this
into `4.x` (see #3690)
### Motivation
Allow for storing of other presented offering context on a `Package`
### Description
- Replaced `offeringIdentifier: String` on `Package` with new
`presentedOfferingContext: PresentedOfferingContext`
- Currently it only has a `offeringIdentifier: String`
- Eventually this will add new optional properties for targeting and
placements
Fixes#3655.
Follow up to #3633.
This improves upon that PR by using `Locale.current.removingRegion` for
each locale **before** trying to look up `Locale.preferredLocales`.
This fixes this scenario:
- Configure your phone with: `en_IN` and `es_ES`
- Launch paywall with localization `en_US` and `es_ES`
Prior to this change, we'd be looking up localizations in this order:
- `en_IN`
- `es_ES`
- `en` (no region)
Because there _is_ a localization for `es_ES` we'd return that one
before attempting to look up `Locale.current.removingRegion`.
Example of the new log:
> VERBOSE: Looking up localized configuration for ["en_IN", "es_ES"],
searching for ["en_IN", "en", "es_ES", "es"]
> VERBOSE: Found localized configuration for 'en'
See https://github.com/RevenueCat/khepri/pull/6798.
This can help debug issues as it helps us cross-reference requests on
our end.
### Changes:
- Logging `X-Request-ID` and `X-Amzn-Trace-ID` headers if present (only
in verbose mode)
- Logging these even for failed requests
- Also logging `request_handled_by_load_shedder` for failed requests
- To accomplish that. `HTTPURLResponse` now conforms to our
`HTTPResponseType`, this allows us to extract headers in an uniform way
### Example:
```
[network] DEBUG: ℹ️ API request completed: GET '/v1/subscribers/$RCAnonymousID%3Ad9dc4f3b33054cf28c0f2ec9da0f48d5' (304)
Request-ID: 'be534c50-5231-403b-be2f-7c077861751d'; Amzn-Trace-ID: 'Root=1-6532c6ca-7ef80f961ec9c4145484df13'
```
This modifies `BaseStoreKitIntegrationTests.printReceipt` to also log
`StoreKit 2` transactions which can be used to debug failures of
`OfflineStoreKitIntegrationTests`.
`PurchasedProductsFetcher` also logs how many `currentEntitlements` it
found since failures are coming from there.
### Description
StoreKit displays different types of in-app messages on app launch, but
it provides an API to disable that and show these messages at a
different time. This PR wraps those APIs to make them easier to use
through our SDK.
#### New APIs
- Add new configuration parameter to defer StoreKit's automatic in-app
messages
- Add new method in `Purchases` to display a list of specific message
types
- New `StoreKitMessageType` enum mapping to the types used by StoreKit
---------
Co-authored-by: NachoSoto <NachoSoto@users.noreply.github.com>
Since #3032 developers using observer mode need to configure the SDK
with the correct StoreKit 2 setting.
A future API will allow users to sync transactions made with StoreKit2.
### Changes:
- Added `PurchasesOrchestrator` tests for paywall data sent through post
receipt
- Fixed and tested state issue with cached paywall data and failed
purchases
- Added `Integration Tests` for tracking and flushing events
- Configured `Integration Tests` with a custom documents directory to
ensure it's empty on every test invocation
- Changed deployment target on `Integration Tests` to iOS 16 to simplify
code
- Setting `Purchases.logLevel` before configuring purchases on
`Integration Tests`
- Added assertion to ensure `FileHandler` never runs on the main thread
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.
This relies on `AsyncImage` using `URLSession.shared`
I've tested this, and the app doesn't even make a request the second
time around because the images are caches on disk.
We get a lot of flaky failures on `OfflineStoreKitIntegrationTests`
(example:
https://app.circleci.com/pipelines/github/RevenueCat/purchases-ios/14843/workflows/a3cffa7c-da25-4811-931d-fc241befee0f/jobs/118214/tests).
I managed to reproduce locally running some of these 100+ times on a
loop until they failed.
I traced the issue to this sequence:
- SK2 purchase
- `StoreKit.Transaction.unfinished` is empty
This is likely due to a race condition. In practice, when computing
offline `CustomerInfo`, we never check it right away, since we need to
wait for the server to return a 500.
I filed a Radar for this: `FB13133387`.
To make tests more realistic, I introduced a delay in `HTTPClient`
returning fake 5xx errors. I haven't been able to reproduce this issue
again, so this should make `OfflineStoreKitIntegrationTests` more
reliable.
I've also added a new debug log that could help debug similar issues,
because `Strings.offline.computed_offline_customer_info` was not very
complete.
Fixes#3020.
This essentially reverts #2890.
As shown in #3020, downgrades are reported with the same transaction
identifier as the original transaction. Therefore our approach in #2890
is invalid.
We considered checking whether the `SKPayment` in the
`SKPaymentTransaction` matches the one added to the queue as a way to
disambiguate these transactions, but it doesn't. So we have no way of
telling the difference between queue transactions and purchases.
I've left the original tests as a way to at least document this
behavior.
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.
This paves the way to add a second set of paths with a separate server
URL to be able to shed requests to offerings and product entitlement
mapping to a separate server.
### Changes:
- New `HTTPRequestPath` with the same properties as `HTTPRequest.Path` +
a new `serverHostURL`
- Moved proxy logic from `SystemInfo`/`HTTPClient` to `HTTPRequestPath`
- Simplified `HTTPRequest` implementation