mirror of
https://github.com/RevenueCat/purchases-ios
synced 2026-11-09 22:35:56 +05:45
main
174
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
7c12636671 |
Enable API sources for everyone by removing the usesRemoteConfigAPISources dangerous setting (#8195)
Co-authored-by: Cursor <cursoragent@cursor.com> |
||
|
|
0efd41b2ce |
fix: Reduce CPU usage when downloading large assets (#8177)
* fix: Reduce CPU usage when downloading large assets * fix: Release checksum buffers while hashing files * test: Remove duplicate file repository tests |
||
|
|
6ed515865a | Simplify HTTP path types (#7987) | ||
|
|
eac1656a40 |
Add the hosted checkout session status endpoint (#7885)
* Decode the in-app web checkout open method Marks a web purchase button as opening its checkout in the in-app bottom sheet. Nothing reads it yet: routing lands with the button wiring. Where the method reaches Browser.navigateTo there is no sheet to present, so it takes the in-app browser rather than opening nothing. Co-authored-by: Cursor <cursoragent@cursor.com> * Log the in-app browser fallback for the checkout open method Co-authored-by: Cursor <cursoragent@cursor.com> * Model the checkout web view lifecycle as a single state The three booleans tracking the web view's progress could combine into states that cannot happen, and `hasFinished` guarded only the callback: a navigation still in flight after the return URL could set `.failed` and blank the page while the host was dismissing the sheet. Co-authored-by: Cursor <cursoragent@cursor.com> * Hold the checkout status until a host is listening Detents without `presentationContentInteraction`, iOS 16.4, turn a drag on the page into a sheet resize, so 16.0 to 16.3 gets the full-height sheet rather than an unscrollable medium one. The sheet assigns `onFinished` on appearance, and loading can start before that, so a page reaching a return URL first would drop it. Co-authored-by: Cursor <cursoragent@cursor.com> * Trim comments in the checkout sheet presentation Co-authored-by: Cursor <cursoragent@cursor.com> * Add the hosted checkout entry point the paywall can call Composes the external purchase preparation and the hosted checkout call behind an @_spi(Internal) entry point on Purchases, so RevenueCatUI can start a checkout with a single call. Co-authored-by: Cursor <cursoragent@cursor.com> * Decode the hosted web checkout purchase button method Co-authored-by: Cursor <cursoragent@cursor.com> * Give the checkout sheet its own object ID in the Xcode project Co-authored-by: Cursor <cursoragent@cursor.com> * Resolve the hosted checkout open method in the model Co-authored-by: Cursor <cursoragent@cursor.com> * Leave the open method default to the caller Co-authored-by: Cursor <cursoragent@cursor.com> * Take the hosted checkout method without an open method Co-authored-by: Cursor <cursoragent@cursor.com> * Add the checkout web view host Owns the WKWebView that presents a payment provider's checkout page, detects the return URL that ends the flow, and reports success or cancellation to its host. Navigations are not restricted: the checkout URL comes from RevenueCat's backend and the page it opens is free to reach the provider's own domains and any bank's 3DS challenge. Co-authored-by: Cursor <cursoragent@cursor.com> * Take both return URLs from the checkout start response The backend now returns success_url and cancel_url alongside checkout_url, so the outcome is told by which one the page navigates to rather than by a query parameter the SDK knows the name of. Co-authored-by: Cursor <cursoragent@cursor.com> * Compare the root return path consistently URL.path strips trailing slashes for every path but the root, so the length guard was the only thing keeping https://host/ and https://host from matching. Co-authored-by: Cursor <cursoragent@cursor.com> * Drop the unused reload from the checkout view model Nothing calls it until something presents a retry, so leave it to whichever change needs it. Co-authored-by: Cursor <cursoragent@cursor.com> * Trim comments in the checkout web view Co-authored-by: Cursor <cursoragent@cursor.com> * Model the checkout web view lifecycle as a single state The three booleans tracking the web view's progress could combine into states that cannot happen, and `hasFinished` guarded only the callback: a navigation still in flight after the return URL could set `.failed` and blank the page while the host was dismissing the sheet. Co-authored-by: Cursor <cursoragent@cursor.com> * Leave the Test Store out of the hosted checkout A test_ key stops at the eligibility gate and reports external purchases as unavailable, so the caller buys through StoreKit rather than opening a checkout the Test Store does not support yet. Co-authored-by: Cursor <cursoragent@cursor.com> * Restore Package.resolved Co-authored-by: Cursor <cursoragent@cursor.com> * Follow the external purchase outcomes the manager reports now The manager tells payment authorization apart from eligibility and stops a preparation asked for while another is under way, neither of which existed when the checkout was written. It also reuses the instance the link-out flow uses, so a single preparation runs at a time however the customer is taken to the checkout. Co-authored-by: Cursor <cursoragent@cursor.com> * Add the checkout web view host Owns the WKWebView that presents a payment provider's checkout page, detects the return URL that ends the flow, and reports success or cancellation to its host. Navigations are not restricted: the checkout URL comes from RevenueCat's backend and the page it opens is free to reach the provider's own domains and any bank's 3DS challenge. Co-authored-by: Cursor <cursoragent@cursor.com> * Take both return URLs from the checkout start response The backend now returns success_url and cancel_url alongside checkout_url, so the outcome is told by which one the page navigates to rather than by a query parameter the SDK knows the name of. Co-authored-by: Cursor <cursoragent@cursor.com> * Compare the root return path consistently URL.path strips trailing slashes for every path but the root, so the length guard was the only thing keeping https://host/ and https://host from matching. Co-authored-by: Cursor <cursoragent@cursor.com> * Drop the unused reload from the checkout view model Nothing calls it until something presents a retry, so leave it to whichever change needs it. Co-authored-by: Cursor <cursoragent@cursor.com> * Trim comments in the checkout web view Co-authored-by: Cursor <cursoragent@cursor.com> * Model the checkout web view lifecycle as a single state The three booleans tracking the web view's progress could combine into states that cannot happen, and `hasFinished` guarded only the callback: a navigation still in flight after the return URL could set `.failed` and blank the page while the host was dismissing the sheet. Co-authored-by: Cursor <cursoragent@cursor.com> * Open the checkout the purchase button asks for in a sheet A purchase button configured for the in-app checkout now runs Apple's flow, creates the session and presents it, rather than buying through StoreKit. The page reporting success or a cancellation takes the sheet down and settles the paywall; a customer who closes the sheet without the page saying anything is left for the backend to settle, which is not wired up yet. A device that cannot take part in Apple's programme still buys through StoreKit, as an SDK that does not know this purchase method would. Co-authored-by: Cursor <cursoragent@cursor.com> * Let the checkout page reach the bottom edge Stopping above the home indicator leaves a strip of the host's background under a page that fills its sheet. The web view insets its own content by the safe area, so nothing the page puts at the bottom goes out of reach. Co-authored-by: Cursor <cursoragent@cursor.com> * Add the checkout web view host Owns the WKWebView that presents a payment provider's checkout page, detects the return URL that ends the flow, and reports success or cancellation to its host. Navigations are not restricted: the checkout URL comes from RevenueCat's backend and the page it opens is free to reach the provider's own domains and any bank's 3DS challenge. Co-authored-by: Cursor <cursoragent@cursor.com> * Take both return URLs from the checkout start response The backend now returns success_url and cancel_url alongside checkout_url, so the outcome is told by which one the page navigates to rather than by a query parameter the SDK knows the name of. Co-authored-by: Cursor <cursoragent@cursor.com> * Compare the root return path consistently URL.path strips trailing slashes for every path but the root, so the length guard was the only thing keeping https://host/ and https://host from matching. Co-authored-by: Cursor <cursoragent@cursor.com> * Drop the unused reload from the checkout view model Nothing calls it until something presents a retry, so leave it to whichever change needs it. Co-authored-by: Cursor <cursoragent@cursor.com> * Trim comments in the checkout web view Co-authored-by: Cursor <cursoragent@cursor.com> * Model the checkout web view lifecycle as a single state The three booleans tracking the web view's progress could combine into states that cannot happen, and `hasFinished` guarded only the callback: a navigation still in flight after the return URL could set `.failed` and blank the page while the host was dismissing the sheet. Co-authored-by: Cursor <cursoragent@cursor.com> * Let the checkout page reach the bottom edge Stopping above the home indicator leaves a strip of the host's background under a page that fills its sheet. The web view insets its own content by the safe area, so nothing the page puts at the bottom goes out of reach. Co-authored-by: Cursor <cursoragent@cursor.com> * Read what the checkout returned off the view model The status was pushed into SwiftUI state in the same breath as the dismissal that reads it, and the view model handed it over and forgot it, leaving nothing to fall back on. A page that returned before the sheet was on screen also asked to be dismissed from inside its own presentation, which SwiftUI is in no position to honour. The status now stays on the view model, the sheet keeps what it presented so it can ask at any time, and a checkout that returned early waits a hop before asking to come down. Co-authored-by: Cursor <cursoragent@cursor.com> * Add the checkout web view host Owns the WKWebView that presents a payment provider's checkout page, detects the return URL that ends the flow, and reports success or cancellation to its host. Navigations are not restricted: the checkout URL comes from RevenueCat's backend and the page it opens is free to reach the provider's own domains and any bank's 3DS challenge. Co-authored-by: Cursor <cursoragent@cursor.com> * Take both return URLs from the checkout start response The backend now returns success_url and cancel_url alongside checkout_url, so the outcome is told by which one the page navigates to rather than by a query parameter the SDK knows the name of. Co-authored-by: Cursor <cursoragent@cursor.com> * Compare the root return path consistently URL.path strips trailing slashes for every path but the root, so the length guard was the only thing keeping https://host/ and https://host from matching. Co-authored-by: Cursor <cursoragent@cursor.com> * Drop the unused reload from the checkout view model Nothing calls it until something presents a retry, so leave it to whichever change needs it. Co-authored-by: Cursor <cursoragent@cursor.com> * Trim comments in the checkout web view Co-authored-by: Cursor <cursoragent@cursor.com> * Model the checkout web view lifecycle as a single state The three booleans tracking the web view's progress could combine into states that cannot happen, and `hasFinished` guarded only the callback: a navigation still in flight after the return URL could set `.failed` and blank the page while the host was dismissing the sheet. Co-authored-by: Cursor <cursoragent@cursor.com> * Let the checkout page reach the bottom edge Stopping above the home indicator leaves a strip of the host's background under a page that fills its sheet. The web view insets its own content by the safe area, so nothing the page puts at the bottom goes out of reach. Co-authored-by: Cursor <cursoragent@cursor.com> * Trim comments in the hosted checkout manager Co-authored-by: Cursor <cursoragent@cursor.com> * Trim the remaining hosted checkout result comments Co-authored-by: Cursor <cursoragent@cursor.com> * Send the paywall session without a paywall identifier khepri#24938 made `paywall_id` optional precisely so that iOS could send the session identifier, which is the key the paywall events are joined on. Dropping the whole paywall object for a paywall that has no identifier of its own threw that session away. Also trims the last of the result comments. Co-authored-by: Cursor <cursoragent@cursor.com> * Trim the hosted checkout session comments Co-authored-by: Cursor <cursoragent@cursor.com> * Drop the remaining hosted checkout session comments Co-authored-by: Cursor <cursoragent@cursor.com> * Silence missing docs for the whole session file Co-authored-by: Cursor <cursoragent@cursor.com> * Add the checkout web view host Owns the WKWebView that presents a payment provider's checkout page, detects the return URL that ends the flow, and reports success or cancellation to its host. Navigations are not restricted: the checkout URL comes from RevenueCat's backend and the page it opens is free to reach the provider's own domains and any bank's 3DS challenge. Co-authored-by: Cursor <cursoragent@cursor.com> * Take both return URLs from the checkout start response The backend now returns success_url and cancel_url alongside checkout_url, so the outcome is told by which one the page navigates to rather than by a query parameter the SDK knows the name of. Co-authored-by: Cursor <cursoragent@cursor.com> * Compare the root return path consistently URL.path strips trailing slashes for every path but the root, so the length guard was the only thing keeping https://host/ and https://host from matching. Co-authored-by: Cursor <cursoragent@cursor.com> * Drop the unused reload from the checkout view model Nothing calls it until something presents a retry, so leave it to whichever change needs it. Co-authored-by: Cursor <cursoragent@cursor.com> * Trim comments in the checkout web view Co-authored-by: Cursor <cursoragent@cursor.com> * Model the checkout web view lifecycle as a single state The three booleans tracking the web view's progress could combine into states that cannot happen, and `hasFinished` guarded only the callback: a navigation still in flight after the return URL could set `.failed` and blank the page while the host was dismissing the sheet. Co-authored-by: Cursor <cursoragent@cursor.com> * Let the checkout page reach the bottom edge Stopping above the home indicator leaves a strip of the host's background under a page that fills its sheet. The web view insets its own content by the safe area, so nothing the page puts at the bottom goes out of reach. Co-authored-by: Cursor <cursoragent@cursor.com> * Keep the checkout sheet's callbacks on the main actor The status the sheet reports is read off the view model, which is main actor isolated, so the methods doing the reading have to say they run there. Swift 5.10 does not infer it from the ViewModifier conformance the way newer compilers do, and the iOS 16 job builds with Xcode 15.4. Co-authored-by: Cursor <cursoragent@cursor.com> * Present the in-app web checkout outside Apple's external purchase programme An ineligible customer, or an app that has not opted into the programme, now gets the checkout presented with no external purchase token behind it, the same way a web purchase in the browser is opened today, instead of being routed to a StoreKit purchase. A Test Store API key still leaves the caller to buy through StoreKit, since no checkout session can be created for it. Co-authored-by: Cursor <cursoragent@cursor.com> * Present the in-app web checkout with a Test Store API key The backend creates a sandbox checkout session for a test_ key, so there is no reason to leave the caller to buy through StoreKit instead of presenting the checkout, which is what a web purchase in the browser already does. Apple's programme has no App Store to apply to behind such a key, so the checkout is presented with no notice shown and no token minted. Co-authored-by: Cursor <cursoragent@cursor.com> * Keep the console quiet for apps outside Apple's programme The external purchase side already reports why no external purchase was prepared, and says nothing at all where the app takes no part in the programme, so the checkout has nothing to add. Also covers the window before the notice comes up, where the one checkout at a time rule now decides between a checkout that opens and one that is dropped. Co-authored-by: Cursor <cursoragent@cursor.com> * Say what the outcome does rather than what it used to do Co-authored-by: Cursor <cursoragent@cursor.com> * Say that Apple's custom link does not apply rather than that nothing can be bought An ineligible customer is not a customer who cannot buy outside the App Store: the custom link may simply not apply where they are, and the purchase goes ahead without it. Dropped to debug, since that is the normal case anywhere the link does not reach. Co-authored-by: Cursor <cursoragent@cursor.com> * Drop a note on an outcome no caller acts on Co-authored-by: Cursor <cursoragent@cursor.com> * Drop a note on an outcome no caller acts on Co-authored-by: Cursor <cursoragent@cursor.com> * Remove two conveniences on the preparation result that no caller uses Co-authored-by: Cursor <cursoragent@cursor.com> * Restore the test dependencies dropped from Package.resolved Co-authored-by: Cursor <cursoragent@cursor.com> * Run Apple's external purchase flow with a Test Store key Eligibility, the notice and the token follow the app's entitlement and the customer's storefront, none of which has anything to do with the key the SDK was configured with, and the purchase behind such a token is a sandbox one either way. Skipping the flow left a Test Store key, the shortest path a developer has to trying the checkout out, as the one key that could not exercise it. Reverts the carve-out added in #7643. Co-authored-by: Cursor <cursoragent@cursor.com> * Route the hosted web checkout method to the checkout it asks for The merge left the placeholder branch that sent hosted_web_checkout to StoreKit alongside the one that opens the checkout, and the checkout no longer reports an external purchase being unavailable: it presents itself in that case too. Co-authored-by: Cursor <cursoragent@cursor.com> * Tell the customer when the in-app checkout is refused for a product they own The backend answers 409/7772 for a product the customer already has, which the SDK decoded as an unknown error and reported as a plain failure: the sheet never opened and the tap looked like it did nothing. Co-authored-by: Cursor <cursoragent@cursor.com> * Word the already-owned alert as Apple does, by product category Only the title carries over: Apple's own alert also names the renewal date and offers to manage the subscription, both of which describe a purchase made on the App Store rather than this one. Co-authored-by: Cursor <cursoragent@cursor.com> * Present the in-app web checkout outside Apple's external purchase programme An ineligible customer, or an app that has not opted into the programme, now gets the checkout presented with no external purchase token behind it, the same way a web purchase in the browser is opened today, instead of being routed to a StoreKit purchase. A Test Store API key still leaves the caller to buy through StoreKit, since no checkout session can be created for it. Co-authored-by: Cursor <cursoragent@cursor.com> * Keep the console quiet for apps outside Apple's programme The external purchase side already reports why no external purchase was prepared, and says nothing at all where the app takes no part in the programme, so the checkout has nothing to add. Also covers the window before the notice comes up, where the one checkout at a time rule now decides between a checkout that opens and one that is dropped. Co-authored-by: Cursor <cursoragent@cursor.com> * Say what the outcome does rather than what it used to do Co-authored-by: Cursor <cursoragent@cursor.com> * Say that Apple's custom link does not apply rather than that nothing can be bought An ineligible customer is not a customer who cannot buy outside the App Store: the custom link may simply not apply where they are, and the purchase goes ahead without it. Dropped to debug, since that is the normal case anywhere the link does not reach. Co-authored-by: Cursor <cursoragent@cursor.com> * Drop a note on an outcome no caller acts on Co-authored-by: Cursor <cursoragent@cursor.com> * Remove two conveniences on the preparation result that no caller uses Co-authored-by: Cursor <cursoragent@cursor.com> * Restore the test dependencies dropped from Package.resolved Co-authored-by: Cursor <cursoragent@cursor.com> * Ask for the checkout through the purchase handler The handler is the paywall's way to the SDK, so reaching for the singleton meant checking whether it was configured at all. It never had to: an unconfigured paywall is never rendered, and the handler already stands in for a purchase that cannot be made. Drops the StoreKit fallback that only that check could reach. Co-authored-by: Cursor <cursoragent@cursor.com> * Let the mapping speak for the outcome it produces The init lists what turns into nothing, so the case did not need to repeat it. Co-authored-by: Cursor <cursoragent@cursor.com> * Present the in-app web checkout outside Apple's external purchase programme An ineligible customer, or an app that has not opted into the programme, now gets the checkout presented with no external purchase token behind it, the same way a web purchase in the browser is opened today, instead of being routed to a StoreKit purchase. A Test Store API key still leaves the caller to buy through StoreKit, since no checkout session can be created for it. Co-authored-by: Cursor <cursoragent@cursor.com> * Keep the console quiet for apps outside Apple's programme The external purchase side already reports why no external purchase was prepared, and says nothing at all where the app takes no part in the programme, so the checkout has nothing to add. Also covers the window before the notice comes up, where the one checkout at a time rule now decides between a checkout that opens and one that is dropped. Co-authored-by: Cursor <cursoragent@cursor.com> * Say what the outcome does rather than what it used to do Co-authored-by: Cursor <cursoragent@cursor.com> * Say that Apple's custom link does not apply rather than that nothing can be bought An ineligible customer is not a customer who cannot buy outside the App Store: the custom link may simply not apply where they are, and the purchase goes ahead without it. Dropped to debug, since that is the normal case anywhere the link does not reach. Co-authored-by: Cursor <cursoragent@cursor.com> * Drop a note on an outcome no caller acts on Co-authored-by: Cursor <cursoragent@cursor.com> * Remove two conveniences on the preparation result that no caller uses Co-authored-by: Cursor <cursoragent@cursor.com> * Restore the test dependencies dropped from Package.resolved Co-authored-by: Cursor <cursoragent@cursor.com> * perf: add checkout request lane and generalize backend lanes Checkout tap-path requests now bypass the shared backend serialization stack via a dedicated lane, using the same generalized lane factory as remote config. Co-authored-by: Cursor <cursoragent@cursor.com> * Address review feedback on backend lanes Restore misplaced comments, add BackendLanesTests, and use plain Sendable on BackendLanes now that the justification matches the type. Co-authored-by: Cursor <cursoragent@cursor.com> * Clarify timeoutManager sharing comment wording Co-authored-by: Cursor <cursoragent@cursor.com> * Trim comments around the already-owned alert Co-authored-by: Cursor <cursoragent@cursor.com> * State where the hosted checkout is presented from Presenting the sheet is main actor work, and the await in front of it said the opposite: nothing suspends there, which `pod lib lint` counts as a warning and so refuses the podspec. Annotating the caller rather than dropping the await alone keeps this from depending on whether a given compiler infers a View's methods to be isolated, which is the same disagreement that put the sheet's callbacks on the main actor. Co-authored-by: Cursor <cursoragent@cursor.com> * Present the in-app web checkout outside Apple's external purchase programme An ineligible customer, or an app that has not opted into the programme, now gets the checkout presented with no external purchase token behind it, the same way a web purchase in the browser is opened today, instead of being routed to a StoreKit purchase. A Test Store API key still leaves the caller to buy through StoreKit, since no checkout session can be created for it. Co-authored-by: Cursor <cursoragent@cursor.com> * Keep the console quiet for apps outside Apple's programme The external purchase side already reports why no external purchase was prepared, and says nothing at all where the app takes no part in the programme, so the checkout has nothing to add. Also covers the window before the notice comes up, where the one checkout at a time rule now decides between a checkout that opens and one that is dropped. Co-authored-by: Cursor <cursoragent@cursor.com> * Say what the outcome does rather than what it used to do Co-authored-by: Cursor <cursoragent@cursor.com> * Say that Apple's custom link does not apply rather than that nothing can be bought An ineligible customer is not a customer who cannot buy outside the App Store: the custom link may simply not apply where they are, and the purchase goes ahead without it. Dropped to debug, since that is the normal case anywhere the link does not reach. Co-authored-by: Cursor <cursoragent@cursor.com> * Drop a note on an outcome no caller acts on Co-authored-by: Cursor <cursoragent@cursor.com> * Remove two conveniences on the preparation result that no caller uses Co-authored-by: Cursor <cursoragent@cursor.com> * Restore the test dependencies dropped from Package.resolved Co-authored-by: Cursor <cursoragent@cursor.com> * Test production checkout lane wiring Build Backend through the convenience initializer in the parallel checkout test so removing .checkout from dedicatedLanes fails the suite. Co-authored-by: Cursor <cursoragent@cursor.com> * Restrict RequestLane to canonical constants Replace the synthesized memberwise initializer with a private init so lane values cannot be built ad hoc and miss dedicatedConfigurations keys. Co-authored-by: Cursor <cursoragent@cursor.com> * Clarify apiSourceFailover is shared across lanes Co-authored-by: Cursor <cursoragent@cursor.com> * Remove RequestLane private init doc comment Co-authored-by: Cursor <cursoragent@cursor.com> * Add checkout lane tests for shared lane and token refresh dedupe. Pin that both checkout endpoints share one lane in a single backend, and that concurrent 401s across lanes trigger exactly one token refresh when HTTPClients share a TokenManager. Co-authored-by: Cursor <cursoragent@cursor.com> * Clear HTTP client caches on every backend lane. Backend now retains its BackendLanes and delegates cache clearing to all lane configurations, with a test that asserts each lane's HTTPClient is cleared. Co-authored-by: Cursor <cursoragent@cursor.com> * Cover external purchase token in checkout lane parallel test. Extend the production wiring test so both checkout tap-path requests complete while offerings hangs on the default lane. Co-authored-by: Cursor <cursoragent@cursor.com> * Derive Backend default config from lanes. Remove the redundant backendConfig parameter from Backend's required initializer so config always matches lanes[.default]. Co-authored-by: Cursor <cursoragent@cursor.com> * Warn when a backend lane falls back to the default configuration. Log a warning when a BackendLanes instance has dedicated lanes but the requested lane is missing, so miswired APIs are visible in production without noisy warnings for single-lane bundles. Co-authored-by: Cursor <cursoragent@cursor.com> * Move test-only Backend and BackendLanes initializers to UnitTests. Single-lane construction helpers now live in the test target so production code only exposes the multi-lane factory path. Co-authored-by: Cursor <cursoragent@cursor.com> * Register BackendTestInitializers in StoreKitUnitTests. MockBackend is compiled into both UnitTests and StoreKitUnitTests, so the test-only Backend initializers helper must be in both targets' Sources phases. Co-authored-by: Cursor <cursoragent@cursor.com> * Ask the backend what became of a checkout session The checkout page returning to its success URL says the customer finished paying, not that the purchase has landed, so the session is polled until the backend settles it one way or the other. Co-authored-by: Cursor <cursoragent@cursor.com> * Settle a checkout on the customer and the reason it ended The session belongs to the customer who started it, so the poll asks about that one rather than about whoever is current when an attempt goes out: a customer change mid-poll would otherwise turn a landed purchase into no answer at all. A confirmed purchase is fetched before it is reported, so a caller acting on the outcome finds the entitlement on the customer instead of the state from before the checkout. A fetch that does not land leaves the purchase standing. The failure the backend gives now travels with the outcome, which tells a declined payment from a checkout that never got going. Co-authored-by: Cursor <cursoragent@cursor.com> * Let the view showing the checkout hand its links to the browser The button set onOpenExternalURL on the view model it was about to present, and WebCheckoutView assigns that same closure when it appears. Nothing could reach the button's: the page only starts loading on that appearance, a line after. Co-authored-by: Cursor <cursoragent@cursor.com> * Present the in-app web checkout outside Apple's external purchase programme An ineligible customer, or an app that has not opted into the programme, now gets the checkout presented with no external purchase token behind it, the same way a web purchase in the browser is opened today, instead of being routed to a StoreKit purchase. A Test Store API key still leaves the caller to buy through StoreKit, since no checkout session can be created for it. Co-authored-by: Cursor <cursoragent@cursor.com> * Keep the console quiet for apps outside Apple's programme The external purchase side already reports why no external purchase was prepared, and says nothing at all where the app takes no part in the programme, so the checkout has nothing to add. Also covers the window before the notice comes up, where the one checkout at a time rule now decides between a checkout that opens and one that is dropped. Co-authored-by: Cursor <cursoragent@cursor.com> * Say what the outcome does rather than what it used to do Co-authored-by: Cursor <cursoragent@cursor.com> * Say that Apple's custom link does not apply rather than that nothing can be bought An ineligible customer is not a customer who cannot buy outside the App Store: the custom link may simply not apply where they are, and the purchase goes ahead without it. Dropped to debug, since that is the normal case anywhere the link does not reach. Co-authored-by: Cursor <cursoragent@cursor.com> * Drop a note on an outcome no caller acts on Co-authored-by: Cursor <cursoragent@cursor.com> * Remove two conveniences on the preparation result that no caller uses Co-authored-by: Cursor <cursoragent@cursor.com> * Restore the test dependencies dropped from Package.resolved Co-authored-by: Cursor <cursoragent@cursor.com> * Drop the note on who hands the checkout's links to the browser Co-authored-by: Cursor <cursoragent@cursor.com> * Restore the test dependencies dropped from Package.resolved Co-authored-by: Cursor <cursoragent@cursor.com> * Call what the paywall does with a checkout tap an action Start read as a verb or an event, so HostedCheckout.Start(.failed) looked like it began something. The cases are things the paywall does: present, or nothing. Co-authored-by: Cursor <cursoragent@cursor.com> * Name the already-owned mapping test's type after the rename Co-authored-by: Cursor <cursoragent@cursor.com> * Ask the purchase interceptor before starting a hosted checkout Apps that gate purchases with onPurchaseInitiated (e.g. behind sign in) could be bypassed by the hosted checkout, and Apple's flow may mint a token that must be reported, so the interceptor now runs first. Co-authored-by: Cursor <cursoragent@cursor.com> * Drop the token note from HostedCheckout.start's docs Co-authored-by: Cursor <cursoragent@cursor.com> * Track hosted checkouts as purchase initiated The event was only used for its data to attribute the checkout session, so these purchases never showed up as initiated in paywall analytics, unlike the StoreKit and performPurchase paths. Co-authored-by: Cursor <cursoragent@cursor.com> * Move the checkout poller to its own PR Keeps this PR to the network layer, so it can be reviewed on its own. The poller, HostedCheckoutManager.pollCheckout and Purchases.pollHostedCheckout follow in a stacked PR. Co-authored-by: Cursor <cursoragent@cursor.com> * Share the purchase tap checks between the in-app and hosted checkout paths Co-authored-by: Cursor <cursoragent@cursor.com> * Trim packageForPurchaseTap's doc comment Co-authored-by: Cursor <cursoragent@cursor.com> * Present the in-app web checkout outside Apple's external purchase programme An ineligible customer, or an app that has not opted into the programme, now gets the checkout presented with no external purchase token behind it, the same way a web purchase in the browser is opened today, instead of being routed to a StoreKit purchase. A Test Store API key still leaves the caller to buy through StoreKit, since no checkout session can be created for it. Co-authored-by: Cursor <cursoragent@cursor.com> * Keep the console quiet for apps outside Apple's programme The external purchase side already reports why no external purchase was prepared, and says nothing at all where the app takes no part in the programme, so the checkout has nothing to add. Also covers the window before the notice comes up, where the one checkout at a time rule now decides between a checkout that opens and one that is dropped. Co-authored-by: Cursor <cursoragent@cursor.com> * Say what the outcome does rather than what it used to do Co-authored-by: Cursor <cursoragent@cursor.com> * Say that Apple's custom link does not apply rather than that nothing can be bought An ineligible customer is not a customer who cannot buy outside the App Store: the custom link may simply not apply where they are, and the purchase goes ahead without it. Dropped to debug, since that is the normal case anywhere the link does not reach. Co-authored-by: Cursor <cursoragent@cursor.com> * Drop a note on an outcome no caller acts on Co-authored-by: Cursor <cursoragent@cursor.com> * Remove two conveniences on the preparation result that no caller uses Co-authored-by: Cursor <cursoragent@cursor.com> * Restore the test dependencies dropped from Package.resolved Co-authored-by: Cursor <cursoragent@cursor.com> * Add hosted checkout status snapshots and fix the email escaping expectation * Read the hosted checkout status at the top level of the response The backend now returns the operation's fields directly instead of nesting them under `operation`. Co-authored-by: Cursor <cursoragent@cursor.com> * Read an unrecognized hosted checkout status as pending The backend only ever sends started, in_progress, succeeded or failed, so an unknown case was a status it never returns. Co-authored-by: Cursor <cursoragent@cursor.com> * Stop reading in_progress as a hosted checkout status The backend no longer sends it. Should it still arrive, it reads as pending like any status the SDK does not recognize. Co-authored-by: Cursor <cursoragent@cursor.com> * Trim hosted checkout status comments Co-authored-by: Cursor <cursoragent@cursor.com> * Move the hosted checkout query escaping next to the path escaping Co-authored-by: Cursor <cursoragent@cursor.com> * Read an in-progress hosted checkout session as still under way purchases-js polls on it too, and the SDK was warning about it as unrecognized. * Add request snapshots for the in-progress hosted checkout status test * Say where hosted checkout failure codes come from * Join the hosted checkout status cache key with a hyphen, like other operations * Say what the hosted checkout status endpoint gives in plainer words --------- Co-authored-by: Cursor <cursoragent@cursor.com> |
||
|
|
a2733a1bc8 |
feat(ads): send ad unit id to backend during reward polling (#7872)
* feat(ads): send ad unit id to backend during reward polling * refactor: built in url encoding * refactor: change ad unit position |
||
|
|
6026b4ff1f | Enable response signature verification for web purchase redemption (#7874) | ||
|
|
3f8eb5c64b |
Add signature verification failure context to diagnostics (#7800)
* Extend signature verification diagnostics * Restore diagnostics response code semantics * Simplify diagnostics event timestamp handling * Restore diagnostics ETag hit semantics |
||
|
|
db27086ee1 |
Extract signature verification failure reasons (#7777)
* Extract signature verification failure reasons * Log missing signed payload failures |
||
|
|
bd418527c6 |
Remove assertion (#7665)
The non-IAM path is used when logging, so even when IAM is enabled, this can still cause issues. Attempting to spend without enabling IAM will still fail. |
||
|
|
8b024e8009 |
feat(remote-config): remove the remote config session kill switch (#7524)
* Remove remote config kill switch # Conflicts: # Sources/Checkpoints/CheckpointWorkflowResolver.swift # Tests/UnitTests/Checkpoints/CheckpointWorkflowResolverTests.swift * Improve remote config retry tests * feat(remote-config): stop decoding offering paywall components * test(remote-config): align workflow integration test naming * refactor(remote-config): limit offerings preview APIs to debug builds * refactor(remote-config): address review cleanup * fix(remote-config): propagate workflow fetch failures * refactor(remote-config): derive paywall components from decoding mode * Remove offerings paywall component decoding * Update offerings component decoding tests * Fix CI test fixture compilation * Fix offerings initializer argument order * Preserve default offerings component marker |
||
|
|
f14a56dbdf |
Add the networking layer for starting a hosted checkout (#7627)
* Add the networking layer for starting a hosted checkout POST /rcbilling/v1/hosted-checkout creates the checkout session with the payment provider and returns the page to present for it. Co-authored-by: Cursor <cursoragent@cursor.com> * Send the Apple external purchase token id when starting a checkout The endpoint ties the checkout session to the registered token, so the token id is also part of the request's dedup key: two taps carrying different tokens must never share one session. Co-authored-by: Cursor <cursoragent@cursor.com> * Document what PostHostedCheckoutOperation is for Co-authored-by: Cursor <cursoragent@cursor.com> * Retry the hosted checkout request Retries only ever fire on a 429, which the server rejects before creating a session, so there is nothing to strand. Giving up instead costs the customer the tap they are waiting on. Co-authored-by: Cursor <cursoragent@cursor.com> * Name a case where no external purchase token applies Co-authored-by: Cursor <cursoragent@cursor.com> * Drop the comments on the hosted checkout path cases Co-authored-by: Cursor <cursoragent@cursor.com> * Generating new test snapshots (#7633) * Generating new test snapshots (#7634) * Generating new test snapshots (#7635) * Generating new test snapshots (#7636) * Generating new test snapshots (#7637) * Generating new test snapshots (#7638) * Generating new test snapshots (#7639) * Generating new test snapshots (#7640) * Generating new test snapshots (#7641) * Cover the created status of a new checkout session The endpoint answers 201, which is the status the flow actually reads. Co-authored-by: Cursor <cursoragent@cursor.com> * Spell ID and URL the way the rest of the SDK does The decoder converts from snake case, which yields Id and Url, so the names have to be mapped rather than inferred. Co-authored-by: Cursor <cursoragent@cursor.com> * Send the presented offering context when starting a checkout A purchase made through the in-app sheet has to carry the same attribution a StoreKit purchase from that paywall would, so placement, targeting rule, workflow, step and paywall travel with it. Co-authored-by: Cursor <cursoragent@cursor.com> * Add snapshots for the presented offering context request Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: RevenueCat Git Bot <72824662+RCGitBot@users.noreply.github.com> |
||
|
|
c6ccc5e8d8 |
Add the networking layer for registering external purchase tokens (#7596)
* Add the networking layer for registering external purchase tokens Registers a StoreKit external purchase token with the backend so the resulting identifier can be handed to the checkout page. Nothing calls this yet. Co-authored-by: Cursor <cursoragent@cursor.com> * Cover the created status of a first token registration The backend answers 201 the first time a token is stored and 200 when the same token is re-submitted. Co-authored-by: Cursor <cursoragent@cursor.com> * Don't send an ETag when registering an external purchase token ETag responses are cached by URL alone, and every registration posts a different body to the same URL, so a cached response could be replayed for a token it doesn't belong to. Co-authored-by: Cursor <cursoragent@cursor.com> * Read only the identifier from the external purchase token response The checkout page only needs the registration identifier, so the remaining response fields have no consumer in the SDK. Also drop the hand-written request body encoding, which the compiler synthesizes identically, and the signature content, which this endpoint doesn't sign. Co-authored-by: Cursor <cursoragent@cursor.com> * Generating new test snapshots (#7606) * Generating new test snapshots (#7607) * Generating new test snapshots (#7608) * Generating new test snapshots (#7611) * Generating new test snapshots (#7610) * Generating new test snapshots (#7609) * Generating new test snapshots (#7612) * Generating new test snapshots (#7614) * Generating new test snapshots (#7615) * Document what PostExternalPurchaseTokenOperation is for Co-authored-by: Cursor <cursoragent@cursor.com> * Drop the comment on ExternalPurchaseTokenResponse Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: RevenueCat Git Bot <72824662+RCGitBot@users.noreply.github.com> |
||
|
|
a9b2a0ee0c | fix: send X-Is-Sandbox header when using a Test Store API key (#7624) | ||
|
|
a37eca4c8e | Expose access token via AuthenticationDelegate (#7536) | ||
|
|
a1781cb28e | (Internal) Spend virtual currencies (#7507) | ||
|
|
4e98dd2f75 |
Inject and refresh IAM tokens (#7440)
* ci: announce modifications in the SDK API feed (#7451) An attribute-only modification is neither an addition nor a break, so it reached the feed as a headline and a link, with the headline claiming new API. * Implement the IAM network operations # Conflicts: # RevenueCat.xcodeproj/project.pbxproj # Conflicts: # RevenueCat.xcodeproj/project.pbxproj # Conflicts: # Sources/Authentication/Authentication.swift # Sources/Identity/IdentityManager.swift # Tests/UnitTests/Networking/BackendErrorTests.swift * target membership * Inject tokens and attempt to refresh when necessary * testa and a full comment * senior engineer, reporting for duty * custom compilation guard * retain the authentication instance so errors can be reported --------- Co-authored-by: Álvaro Brey <alvaro.brey@revenuecat.com> |
||
|
|
8349a4afd1 |
Implement IAM Login Operations (#7439)
* ci: announce modifications in the SDK API feed (#7451) An attribute-only modification is neither an addition nor a break, so it reached the feed as a headline and a link, with the headline claiming new API. * Implement the IAM network operations # Conflicts: # RevenueCat.xcodeproj/project.pbxproj # Conflicts: # RevenueCat.xcodeproj/project.pbxproj * target membership # Conflicts: # RevenueCat.xcodeproj/project.pbxproj * fix test * address cursor feedback * fix imports * revert fetch policy * missed one --------- Co-authored-by: Álvaro Brey <alvaro.brey@revenuecat.com> |
||
|
|
3eb01c32bc |
HTTP Paths can change their absolute path based on IAM enablement (#7422)
* ci: announce modifications in the SDK API feed (#7451) An attribute-only modification is neither an addition nor a break, so it reached the feed as a headline and a link, with the headline claiming new API. * HTTP Paths can change their absolute path based on IAM enablement * missed one * fix failures * fix crashing test * address feedback * unit tests * slightly more graceful * fix signing test * fix other test * log on invalid path * throwAssertion is unavailable on watchOS * fix backend integration tests * fix import --------- Co-authored-by: Álvaro Brey <alvaro.brey@revenuecat.com> |
||
|
|
4524b91148 |
IAM Login part 2 (#7410)
* ci: announce modifications in the SDK API feed (#7451) An attribute-only modification is neither an addition nor a break, so it reached the feed as a headline and a link, with the headline claiming new API. * Add the Authentication namespace * Add TokenManager * Tests * add test file to xcodeproj * Add API tests * expose authentication property * swift API test * more fixes * lint * Update baseline swiftinterface files (#7421) * sigh * extra parameter * missing arg * a couple more * Fix visibility * fix backend test compilation * remove unnecessary delegate parameter * remove commented-out APIs * add KeychainAccessGroup api * -_- * fix test * don't include attributes values in the description * Address feedback * lint * address feedback --------- Co-authored-by: Álvaro Brey <alvaro.brey@revenuecat.com> Co-authored-by: RevenueCat Git Bot <72824662+RCGitBot@users.noreply.github.com> |
||
|
|
80f6f1a6cb | IAM Login, Part 1 (#7398) | ||
|
|
e7384b8282 |
Re-tier HTTP request timeouts around per-host memory (#7179)
* Re-tier HTTP request timeouts around per-source memory Replaces the single global "recent timeout" slot in `HTTPRequestTimeoutManager` with a per-host map (`host -> lastTimeoutDate`, 10-minute per-entry expiry, pruned on access) and re-tiers the request timeouts: - Main-source, endpoint with no fallback-URL support: 15s base / 5s reduced - Main-source, endpoint with fallback-URL support: 5s base / 2s reduced - Fallback-host and proxied requests: flat 30s (never consult the memory) A main-source request to host H uses the reduced tier iff H has a non-expired timeout entry. Timeouts are now recorded for any non-fallback request (dropping the previous fallback-support-only condition), keyed by attempt host; a successful main-source response clears only that host's entry. Renames the `timeoutOnMainBackendForFallbackSupportedEndpoint` result to `mainSourceTimedOut`. Co-authored-by: Cursor <cursoragent@cursor.com> * Define flat timeout tier as a Timeout constant Co-authored-by: Cursor <cursoragent@cursor.com> * Add exhaustive multi-host tests for timeout manager Co-authored-by: Cursor <cursoragent@cursor.com> * Honor developer networkTimeout via internal NetworkTimeout enum Introduce an internal NetworkTimeout enum (.default/.custom) threaded from Configuration down to HTTPRequestTimeoutManager. When a developer sets networkTimeout, it replaces the built-in base/flat tiers while the reduced fail-fast tiers stay fixed. Co-authored-by: Cursor <cursoragent@cursor.com> * Add NetworkTimeout.swift to RevenueCat.xcodeproj The file was only picked up by the SPM/Tuist directory globs, so the hand-maintained Xcode project build failed to resolve the type. Co-authored-by: Cursor <cursoragent@cursor.com> * Gate re-tiered no-fallback timeouts behind usesRemoteConfigAPISources The aggressive no-fallback fail-fast timeouts and their per-host timeout recording now apply to main-API requests only when the usesRemoteConfigAPISources dangerous setting is enabled. With it disabled (the default) the legacy network timeout is preserved. The gating is a per-call timeout(...) parameter so blob-source downloads can opt into the re-tiered timeouts independently. Co-authored-by: Cursor <cursoragent@cursor.com> * Keep the legacy flat timeout when re-tiered timeouts are off, and share one manager The fallback-host and proxied tiers returned the new 30s flat timeout regardless of `usesRemoteConfigAPISources`, halving the legacy 60s (or custom) network timeout for proxy users and every fallback-host request. They now honor `reTieredTimeoutsEnabled` like the no-fallback tier does, so only the fallback-supported tiers behave the same either way. `Backend` also built one timeout manager per HTTPClient, so the main and remote-config lanes kept independent per-host memories despite talking to the same hosts. They now share one, and `HTTPClient` requires both the manager and the network timeout so this wiring can't be dropped silently. Co-authored-by: Cursor <cursoragent@cursor.com> * Clear a host's fail-fast memory as soon as it answers The memory exists to remember how quickly a host responds, so a non-error response is evidence the host recovered even when decoding or verifying it fails afterwards. Deciding off the raw status code instead of the parsed result keeps those failures from leaving a responsive host stuck on the reduced timeout tier. Co-authored-by: Cursor <cursoragent@cursor.com> * Name the requestTimeoutManager in the fail-fast comment Co-authored-by: Cursor <cursoragent@cursor.com> * Tighten comments on the Backend-shared failover and timeout manager Name the instances the comments describe so it's clear what is shared. Co-authored-by: Cursor <cursoragent@cursor.com> * Record API source timeouts before health checks * Test shared timeout state across backend lanes * Normalize URL timeout classification across platforms * Remove unused RemoteConfigAPI test hook * Skip HTTP stubs lane tests on watchOS * Treat the remote config fallback path as a fallback host request `FallbackPath` bakes the fallback host into its `serverHostURL`, but `fallbackUrlIndex` is nil on those requests, so `isFallbackURLRequest` was false and they were classified as main-source: with API sources enabled they drew the aggressive 15s/5s no-fallback tiers instead of the flat fallback timeout, their timeouts armed the per-host fail-fast memory for the fallback host, and their successes cleared the main source's entry. Add `isFallbackHostPath` so a path can declare that it targets a fallback host on its own, and use it alongside the index when picking the tier and updating the memory. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Rick van der Linden <rick.vanderlinden@revenuecat.com> |
||
|
|
30317dc720 |
feat(remote-config): force the config kill-switch via query param in E2E tests (#7317)
* refactor: model ForceServerErrorStrategy interception as a single Action enum The two closures (`fakeResponseWithoutPerformingRequest` and `shouldForceServerError`) encoded an implicit precedence rule that only lived in a doc comment. A single closure returning `Action` makes each request resolve to exactly one outcome. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: log the destination URL and faked status code when forcing errors Both messages previously identified only the request, so a faked 404 and a faked 204 looked identical, and there was no way to tell whether a forced error routed to the 502 endpoint or to an unreachable host. Co-authored-by: Cursor <cursoragent@cursor.com> * feat(remote-config): force the config kill-switch via force_killswitch query param The Maestro E2E app faked a local 404 for /v1/config, which never exercised the backend's real kill-switch response. RevenueCat/khepri#23311 adds an opt-in `force_killswitch=true` query param that serves the actual 400 CONFIG_ENDPOINT_DISABLED for that request only. `serverErrorURL` cannot be reused here because it replaces the whole URLRequest, dropping the method, auth headers and body the endpoint needs. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com> |
||
|
|
96a3102be3 |
refactor: model ForceServerErrorStrategy interception as a single Action enum (#7315)
* refactor: model ForceServerErrorStrategy interception as a single Action enum The two closures (`fakeResponseWithoutPerformingRequest` and `shouldForceServerError`) encoded an implicit precedence rule that only lived in a doc comment. A single closure returning `Action` makes each request resolve to exactly one outcome. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: log the destination URL and faked status code when forcing errors Both messages previously identified only the request, so a faked 404 and a faked 204 looked identical, and there was no way to tell whether a forced error routed to the 502 endpoint or to an unreachable host. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com> |
||
|
|
f620703c93 |
feat(remote-config): send X-RC-Last-Refresh-Time on config requests (#7308)
* Add config last refresh header * Refine remote config refresh header * Address remote config refresh header reviews * Sign final merged request headers * Snapshot remote config refresh time * Improve remote config refresh timestamp tests |
||
|
|
8925524293 |
feat(remote-config): health-check-gated API source failover in HTTPClient (#7294)
* feat(remote-config): health-check-gated API source failover in HTTPClient Wires the API source failover into the request pipeline, still behind the usesRemoteConfigAPISources dangerous setting (internal and defaulting to false, so released behavior is unchanged): - Requests resolve their API source once (in start) and carry the handle, so a failure can report the exact source unhealthy and ETag/backoff retries keep targeting the same host. - On a failure that points at the host, the source is health-checked before failing over: a healthy source surfaces the original error, an unhealthy one is reported and the request transparently retries on the next source (capped at 5 source attempts per request). The static per-endpoint fallback hosts stay the last resort. - Unlike Android, device-connectivity errors (reusing NetworkError.isAllowedToRetryWithFallbackHost) skip the health check entirely and surface the original error: switching hosts can't fix a device that is offline. - Both Backend HTTPClients share one APISourceFailover, so they walk one source list and share one health-check cache. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(remote-config): end-to-end API source failover integration tests Port of purchases-android's BackendAPISourceFailoverIntegrationTest: real Backend.getCustomerInfo through the production wiring (real HTTPClients, shared APISourceFailover, real SourceHealthChecker probing over the URL loading system) and a real RemoteConfigSourceProvider fed by an in-memory sources topic, with OHHTTPStubs standing in for the source hosts and recording exact per-host request order. Adds the iOS-only device-connectivity scenario and a dangerous-setting-off wiring check on top of Android's seven scenarios. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor: model the request's API source as a single state enum Replaces the three loosely coupled Request fields (apiSource, apiSourceResolved, apiSourceAttemptCount) with one APISourceState enum (unresolved / noSource / source(_, attemptCount:)), making inconsistent combinations unrepresentable. No behavior change. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor: adopt the narrowed APISourceFailover initializer Passes just the usesRemoteConfigAPISources flag at the two construction sites, following the signature change in the base branch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: clarify why static fallback still runs when the source is healthy Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(remote-config): raise the API failover restart interval to 2 hours The api source list now returns to the primary source 2 hours after it first failed over instead of 10 minutes. Timer tests derive their time values from the constant so future changes are single-line. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(remote-config): cover shared failover and probe-in-flight ordering Addresses review feedback: - Both HTTPClient lanes share one APISourceFailover: a failover on the main lane advances the source list the remote-config lane resolves. - A request enqueued while a health check is in flight stays queued until the probe resolves and runs after the front-queued failover retry. - Bind the source and attempt count in one pattern match at the failover guard. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor: drop the APISourceState.attemptCount accessor Its only caller pattern-matches instead, surfacing the "must already target a source" assumption at the one place it matters rather than papering over impossible states with a 0 default. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
5e6d58abf2 |
Avoid decoding legacy paywall components for workflows (#7264)
* Avoid decoding paywall components with remote config * Preserve offerings cache when remote config disables * Prevent stale offerings cache writes after remote config disable * Clarify offerings decoding mode naming * Separate offerings fetch data from cached contents * Address offerings cache review feedback * Require explicit cache decoders * Test offerings cache version compatibility |
||
|
|
c4d824d68c |
feat(remote-config): source API base host from remote-config sources (#7123)
* feat(remote-config): wire manager behind feature flag * fix(remote-config): only mark refresh fresh after cache write * feat(remote-config): source API base host from remote-config sources Resolve the API base host from the shared RemoteConfigSourceProvider (with the embedded defaults as a floor) instead of the hardcoded apiBaseURL, so requests can fail over across API hosts. proxyURL and an overridden apiBaseURL still pin the host and bypass API sources, and per-endpoint fallback URLs are unchanged. The provider is created once in the composition root (gated on remoteConfigEnabled) and injected explicitly into Backend/HTTPClient; nil preserves the existing SystemInfo.apiBaseURL behavior. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor(remote-config): derive nullable API source provider and manager from remoteConfigEnabled Create the RemoteConfigSourceProvider (and its shared on-disk cache) up front, gated on remoteConfigEnabled, and build the RemoteConfigManager from the provider's nullability after Backend exists so it can reuse backend.remoteConfigAPI. Keeps a single shared source provider + disk cache across the networking layer and the manager, and avoids touching the disk cache directory when remote config is disabled. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor(remote-config): drop redundant remote-config wiring comment Co-authored-by: Cursor <cursoragent@cursor.com> * refactor(remote-config): drop redundant remote-config manager comment Co-authored-by: Cursor <cursoragent@cursor.com> * refactor(remote-config): simplify APISourceProviding doc comment Co-authored-by: Cursor <cursoragent@cursor.com> * refactor(remote-config): always wire API source provider, gate only disk cache Create the RemoteConfigSourceProvider unconditionally and inject it into Backend/HTTPClient so main-API host resolution runs even when remote config is disabled (embedded default sources as the floor). Only the on-disk cache and the real RemoteConfigManager stay gated on remoteConfigEnabled; the provider's topic store is now optional (nil means defaults-only). Co-authored-by: Cursor <cursoragent@cursor.com> * refactor(remote-config): rename APISourceProviding to APISourceProviderType Co-authored-by: Cursor <cursoragent@cursor.com> * feat(remote-config): resolve web billing host from API sources The web billing paths are hosted on the same api.revenuecat.com host as the main API, so they should resolve their base host from the API source provider too (with failover) instead of the static serverHostURL. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor(remote-config): drop redundant web billing API-sources comments The comments restated what the code and surrounding context already convey. Co-authored-by: Cursor <cursoragent@cursor.com> * test(remote-config): assert ETag retry stays on the API source host Co-authored-by: Cursor <cursoragent@cursor.com> * refactor(remote-config): drop APISourceProviderType, use RemoteConfigSourceProviderType directly The networking layer depended on the narrow APISourceProviderType protocol, an extra abstraction over RemoteConfigSourceProviderType that added little. Fold currentAPISource() into RemoteConfigSourceProviderType and have HTTPClient/Backend depend on it directly. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(remote-config): wire apiSourceProvider into dedicated remote-config lane The main-into-branch merge left `HTTPClient.dedicatedRemoteConfig` (and its call site) without the new `apiSourceProvider` argument, breaking compilation. Thread the provider through so the dedicated `/config` lane also resolves its base host from remote-config API sources, matching the main lane. Co-authored-by: Cursor <cursoragent@cursor.com> * feat: gate remote-config API sources behind usesRemoteConfigAPISources Resolve main-API request hosts from the remote-config API sources only when the `usesRemoteConfigAPISources` dangerous setting is enabled. When disabled (the default), requests keep targeting the static `SystemInfo.apiBaseURL`. Enable the setting in the API-source `HTTPClient` tests and add a test asserting an injected provider is ignored while the setting is off. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(test): pass apiSourceProvider in BackendRemoteConfigLaneTests HTTPClient init The lane test constructed HTTPClient without the now-required apiSourceProvider argument, breaking compilation of the unit test target after merging main. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Rick van der Linden <rick.vanderlinden@revenuecat.com> Co-authored-by: Cursor <cursoragent@cursor.com> |
||
|
|
b82223e5a1 |
feat(remote-config): add static fallback config endpoint (#7182)
* feat(remote-config): add static fallback config endpoint * refactor(remote-config): clarify static fallback path name * test(remote-config): cover static fallback signature failures * fix(remote-config): use static fallback only without cache * test(remote-config): cover static fallback external blobs * refactor(remote-config): align static fallback naming * refactor(remote-config): scope static fallback path * fix(remote-config): mark fallback no content fresh * fix(remote-config): align fallback accept headers * refactor(remote-config): rename fallback endpoint types * fix(remote-config): enable etags for fallback config * fix(remote-config): skip fallback when proxy is configured * fix(remote-config): rely on etag cache for fallback * refactor(remote-config): reuse fallback retry eligibility * fix(remote-config): disable after fallback client errors |
||
|
|
cbdc1ae216 |
Don't switch hosts on device-connectivity errors (#7176)
* other(networking): don't switch hosts on device-connectivity errors Add `NetworkError.isDeviceConnectivityError` (true only for device-side URLError codes) and narrow the fallback-host retry gate to `isTransient && !isDeviceConnectivityError`, so a device that is offline no longer triggers pointless host switching. Host-side and ambiguous failures stay switch-eligible. Co-authored-by: Cursor <cursoragent@cursor.com> * other(networking): make isDeviceConnectivityError doc less usage-specific Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com> |
||
|
|
de6bccae28 |
other(networking): remove unused workflows OperationQueue and HTTP path cases (#7145)
Follow-up to the dead-endpoint-subsystem deletion: the dedicated workflows OperationQueue (allocated on every Backend init, capped at 4 concurrent operations) was only ever consumed by the now-deleted WorkflowsAPI, and HTTPRequestPath's .getWorkflow/.getWorkflows cases had no remaining production caller. Confirmed via grep before removal. |
||
|
|
f4c06c1f4a |
feat(remote-config): add rc container compression support (#7107)
* feat(remote-config): add rc container compression support * fix(remote-config): use rc element encoding header * test(remote-config): align rc container warning logs * Update baseline swiftinterface files (#7108) * fix(remote-config): remove unused accept encoding header case --------- Co-authored-by: RevenueCat Git Bot <72824662+RCGitBot@users.noreply.github.com> |
||
|
|
00251c6e02 |
feat(remote-config): switch config request to v1 and send app user id (#7080)
* fix(remote-config): use v1 config endpoint * feat(remote-config): include app user id in request * feat(remote-config): move domain into config path * fix(remote-config): always encode prefetched blobs * chore(remote-config): remove redundant coding key comment * fix(remote-config): update nonce path test |
||
|
|
48e61c41e9 |
refactor(remote-config): make rc container validation domain-specific (#7074)
* feat(remote-config): make rc container validation domain-specific * refactor(remote-config): share config element validation * refactor(remote-config): decode remote config container in http client * fix(remote-config): align rebased container tests * fix(remote-config): fix remote config signature verification * docs(remote-config): clarify rc container validation comments |
||
|
|
cf77437cc0 |
feat(remote-config): verify RC container signatures (#7046)
* feat(remote-config): use opaque manifest token models * fix(remote-config): simplify manifest token model * feat(remote-config): verify RC container signatures * refactor(remote-config): isolate signature payload context * refactor(remote-config): clarify fetch and signing context * fix(remote-config): log signature payload failures * fix(remote-config): correct checksum mismatch verification tests * fix(remote-config): clarify checksum mismatch test behavior * fix(remote-config): expect enforced checksum verification failure * fix(remote-config): address signing review feedback * fix(remote-config): verify no-content response context * fix(remote-config): expect no-content verification failure code * refactor(remote-config): group signature verified paths |
||
|
|
6330937692 |
feat(remote-config): support RC Container format in network stack and use for remote-config request (#7037)
* feat(remote-config): request RC Container format * fix(remote-config): handle no content responses * chore(remote-config): allow snapshot todo * fix(remote-config): disable response verification * chore(remote-config): document disabled signing * refactor(remote-config): parse RC containers in HTTP stack |
||
|
|
d4f0fb2686 |
refactor(ads): reuse SDK retry classification for poll errors (#7009)
* refactor(ads): move rewardverification to coresdk * refactor(ads): move rewardverification to coresdk * refactor(ads): move rewardverification to coresdk * refactor(ads): remove unnecessary comment * fix(ads): linter and behavior fixes * fix(ads): gate reward-verification VC cache invalidation out of CustomEntitlementComputation * refactor(ads): rename reward-verification transactionId → clientTransactionID * fix(ads): remove duplicated SSV payload logic from AdMob adapter * chore(ads): cover install via injectable * chore(ads): fix linter * refactor(ads): introduce RewardVerificationToken * refactor(ads): add reward_verification_completed log * Update AdapterSDKs/RevenueCatAdMob/Tests/RevenueCatAdMobTests/PresentRewardVerificationTests.swift Co-authored-by: Antonio Pallares <ajpallares@users.noreply.github.com> * fix(ads): linter * fix(ads): key reward verification poll retries on HTTP status Retry network/offline transport errors and any HTTP 5xx during reward verification polling, keying on the HTTP status code rather than the mapped ErrorCode. Empty/unparseable 5xx responses are now retried, and 4xx client errors fail fast as .backendError instead of being retried to a misleading .timeout. * refactor(ads): drop redundant comments on poll retry classifier * refactor(ads): read poll HTTP status via typed NSError accessor * refactor(ads): reuse SDK retry classification for poll errors Classify reward-verification poll retries from the structured BackendError instead of re-deriving the HTTP status from a flattened public NSError. Adds a reusable NetworkError.isTransient (5xx and connection-level failures are transient, 4xx are terminal) and forwards it from BackendError; the HTTPClient fallback-host retry check now delegates to it. The poll path keeps the structured BackendError via an internal fetchRewardVerificationStatus, letting the poller reuse BackendError.isTransient and dropping the bespoke NSError.httpStatusCode accessor. Co-authored-by: Cursor <cursoragent@cursor.com> * chore(ads): switch from experimental to internal * docs(ads): reference BackendError in Poller doc comment --------- Co-authored-by: Peter Porfy <peter.porfy@revenuecat.com> Co-authored-by: Pol Miro <polmiro@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com> |
||
|
|
50a1b541cb |
WFL-303 | Add fallback URL for workflows (#7008)
* chore: add fallback url for workflows * test: add test * chore: address comments about type param |
||
|
|
f98f57f8e9 |
fix(workflows): Don't serve stale workflows when the backend rejects the request (#6946)
* fix(workflows): don't restore stale workflows from disk on a 4xx The getWorkflowsList failure branch restored the cached list + details on any error. That's right for transient errors (5xx / offline) but wrong for a 4xx, where the backend is authoritatively rejecting the request (workflows disabled, unauthorized), so we shouldn't serve stale prefetched data. Generalize the existing offerings classification into BackendError/NetworkError.shouldFallBackToCache (the offerings property now delegates to it, no behavior change) and gate the workflows disk restore on it. 4xx skips the restore; 5xx and connection failures still restore. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(workflows): fill gaps found in code review of 4xx stale-restore fix (#6948) - testGetWorkflowsListCallsOnCompleteOnClientError: verifies onComplete() fires on 4xx even though disk restore is skipped (callers must not block) - testGetWorkflowsListRestoresDetailsFromDiskOnServerError: verifies that on 5xx both list mapping AND workflow details are restored from disk, not just the list mapping Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor: drop redundant shouldFallBackToCachedOfferings alias Per review: shouldFallBackToCachedOfferings was only a thin delegate to shouldFallBackToCache after the prior refactor. Remove it on NetworkError and BackendError and point callers (OfferingsManager, tests) at shouldFallBackToCache directly. Behavior-preserving. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Cesar de la Vega <664544+vegaro@users.noreply.github.com> |
||
|
|
7ee15c73a2 |
feat(remote-config): add network scaffolding for GET /v2/config endpoint (#6854)
* feat(remote-config): add network scaffolding for GET /v2/config endpoint Adds RemoteConfigResponse model, GetRemoteConfigOperation, RemoteConfigAPI, and wires everything into Backend. Unknown topic keys in the manifest are silently dropped for forward compatibility. Mirrors Android PR #3435. * fix(remote-config): use lowercase "default" variant key in test fixtures Aligns with Android convention (DEFAULT_ENTRY_ID = "default") established in purchases-android PR #3437. * test(remote-config): add snapshot files for all CI OS versions BackendGetRemoteConfigTests was only recording snapshots for iOS 26. CI also runs tests on iOS 14/15/16/17/18, tvOS 18, macOS, and watchOS, each of which requires its own snapshot file. Derived from existing snapshot patterns in BackendGetRewardVerificationStatusTests. * fix(remote-config): log warning when unknown topic key is received Follows the codebase convention of logging unknown backend values (e.g. WorkflowsResponse unknown_workflow_trigger_type) rather than silently dropping them. * refactor(remote-config): rename wireKey to rawValue, add WIP comment for signature verification Aligns Topic with Swift's RawRepresentable convention and matches the Android SDK's comment indicating signature verification will be enabled once the remote config endpoint is finalized. * refactor(remote-config): enable etag and simplify HTTPRequestPath cases |
||
|
|
a3ea621584 |
feat(networking): add getWorkflows list endpoint (#6853)
* feat(networking): add getWorkflows list endpoint Port of https://github.com/RevenueCat/purchases-android/pull/3509 Adds WorkflowSummary and WorkflowsListResponse models, GetWorkflowsListOperation, and WorkflowsAPI.getWorkflows() to support listing a subscriber's workflows via GET /v1/subscribers/{userId}/workflows. Includes request deduplication and background-aware delay, matching the existing getWorkflow(by id) pattern. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Add WorkflowsListCallback and GetWorkflowsListOperation to Xcode project Register the two new source files in the root RevenueCat.xcodeproj so xcodebuild (used by CI) can find them. SPM discovers them automatically but the tracked root project needs explicit file references and build file entries. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix snapshots: remove ?type=paywall query param from getWorkflows endpoint The getWorkflows endpoint does not send a type filter query parameter. The snapshots were created from an earlier design and needed to be updated to match the actual request URL. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix WorkflowSummary decoding with JSONDecoder.default convertFromSnakeCase JSONDecoder.default uses .convertFromSnakeCase, which converts JSON keys to camelCase before matching against CodingKey raw values. The explicit CodingKeys had snake_case raw values (e.g. "display_name"), causing a mismatch since the decoder would convert display_name to displayName before the lookup. Removing the explicit CodingKeys lets Swift synthesize them with camelCase raw values, which correctly match the converted keys. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(networking): add type filter parameter and deserialization robustness tests - Add optional `type: String?` parameter to `getWorkflows` path, operation, API method, and mock, matching Android PR #3509 which supports `?type=<value>` query filtering on the list endpoint - Include `type` in the operation deduplication cache key so calls with different types are not coalesced - Add `testGetWorkflowsWithTypePassesQueryParameter` with snapshots for all OS variants - Add `WorkflowSummaryDecodingTests` covering explicit-null `offering_id`, absent `prefetch` default, and unknown-field tolerance for both `WorkflowSummary` and `WorkflowsListResponse` - Remove unnecessary `import Foundation` from `WorkflowsListCallback.swift` Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Address review: enable signature verification for getWorkflows, fix cache key separator and indentation - Move .getWorkflows to supportsSignatureVerification=true, matching .getWorkflow and Android PR (#3509) - Add "\n" separator between appUserID and type in individualizedCacheKeyPart to prevent collision - Fix indentation of factory closure closing brace and label to match GetWorkflowOperation pattern Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> |
||
|
|
7d7d584634 |
Rename internal SSV symbols, URL and metric to RewardVerification (#6667)
* Rename internal AdMobSSV symbols, URL and metric to RewardVerification Renames the internal AdMobSSV* types and SPI introduced in #6641 to RewardVerification* so the wording stays adapter-agnostic. Also renames the backend URL path and the client-side metric to match, so the SPI is provider-agnostic end to end. Renames: - Sources/Ads/AdMobSSV/ -> Sources/Ads/RewardVerification/ - AdMobSSVPollStatus -> RewardVerificationPollStatus - RewardVerificationPollStatus.validated -> .verified - AdMobSSVStatusResponse -> RewardVerificationStatusResponse - RewardVerificationStatusResponse.Status.validated -> .verified (the decoder still accepts the legacy "validated" wire value during the rollout window; covered by a new decoder unit test) - AdMobSSVStatusCallback -> RewardVerificationStatusCallback - GetAdMobSSVStatusOperation -> GetRewardVerificationStatusOperation - AdsAPI.getAdMobSSVStatus -> getRewardVerificationStatus - Purchases.pollAdMobSSVStatus -> pollRewardVerificationStatus - HTTPRequest.Path.adMobSSVStatus -> .rewardVerificationStatus - URL path: /ads/admob/ssv/{tx} -> /ads/reward_verifications/{tx} - Metric name: get_admob_ssv_status -> get_reward_verification_status - BackendErrorStrings.unknown_admob_ssv_status -> .unknown_reward_verification_status - Test files, test methods and snapshot artifacts renamed to match. - Xcode project file references updated in lockstep. * Drop legacy `validated` wire-value compat in RewardVerification decoder The `RewardVerificationStatusResponse` decoder was accepting both `"verified"` and the legacy `"validated"` status from the backend as a backwards-compat hedge during rollout. The endpoint has not been shipped and the SDK has no public entry point that polls it yet, so there is no compatibility window to preserve — the decoder can require `"verified"` outright. While here, simplify the decoder to look up known cases via `Status(rawValue:)` instead of a string-literal switch, so adding a future case can't silently drift from the enum. Removes the corresponding `testDecodesLegacyValidatedWireValueAsVerified` test. Existing coverage for `verified`, `pending`, `failed`, and the `unknown` fallback (with the `Logger.warn` assertion) is unchanged. * Update Sources/Purchasing/Purchases/Purchases.swift Co-authored-by: Antonio Pallares <ajpallares@users.noreply.github.com> * test: assert warning is logged when decoding literal "unknown" status Address review feedback: cover the case where the backend sends the literal "unknown" wire value, which still falls through the unmapped-status branch and should emit a warning. --------- Co-authored-by: Antonio Pallares <ajpallares@users.noreply.github.com> |
||
|
|
23d012abe2 |
Add workflow-based paywall resolution (#6675)
* Add workflows network layer for multipage paywalls - Add `getWorkflows` and `getWorkflow` endpoint paths - Add `WorkflowsListResponse` and `PublishedWorkflow` response models - Add `WorkflowDetailProcessor` to handle `inline`/`use_cdn` response actions - Add `WorkflowCdnFetcher` protocol and `DirectWorkflowCdnFetcher` implementation - Add `GetWorkflowsOperation` and `GetWorkflowOperation` cacheable network operations - Add `WorkflowsAPI` facade and wire into `Backend` - Add unit tests for all new components iOS equivalent of https://github.com/RevenueCat/purchases-android/pull/3300 Made-with: Cursor * fix xcodeproj * reset Package.resolved * Update WorkflowStep models to match actual backend response - Add WorkflowTrigger struct (name, type, action_id, component_id) - Add triggers, outputs, and metadata fields to WorkflowStep - Add metadata field to PublishedWorkflow - Remove value field from WorkflowTriggerAction (backend uses step_id only) Made-with: Cursor * Update WorkflowResponseTests to match updated models - Replace value/resolvedTargetStepId assertions with stepId - Remove testDecodeWorkflowTriggerActionValueTakesPrecedence (value field removed) - Add testDecodeWorkflowTrigger for new WorkflowTrigger struct - Add testDecodePublishedWorkflowWithMetadata - Update testDecodeWorkflowStepDefaults to cover triggers, outputs, metadata - Add testDecodeWorkflowStepMatchingActualBackendResponse with real backend payload Made-with: Cursor * Disable file_length lint rule in HTTPRequestPath.swift Made-with: Cursor * Fix GetWorkflowOperation to compute result once before distributing to callbacks CDN fetch and JSON decoding were running once per deduplicated callback. Compute the Result<WorkflowFetchResult, BackendError> once outside the performOnAllItemsAndRemoveFromCache loop, matching the pattern used by GetOfferingsOperation and other operations in the codebase. Made-with: Cursor * Fix CDN fetcher: use URLSession instead of Data(contentsOf:), classify errors correctly - Replace Data(contentsOf:) with URLSession.shared.dataTask + DispatchSemaphore in DirectWorkflowCdnFetcher; gets URLSession timeout, HTTP status validation, and proper network stack semantics - Add WorkflowDetailProcessingError.cdnFetchFailed typed error so CDN I/O failures are distinguishable from envelope parsing failures - Catch cdnFetchFailed in GetWorkflowOperation and map to NetworkError.networkError instead of NetworkError.decoding, fixing misleading error classification - Update WorkflowDetailProcessorTests to assert the typed error is thrown Made-with: Cursor * Replace semaphore CDN fetch with async/await using withCheckedThrowingContinuation - Make WorkflowCdnFetcher.fetchCompiledWorkflowData async throws; use withCheckedThrowingContinuation to bridge URLSession.dataTask into async, avoiding any thread-blocking - Make WorkflowDetailProcessor.process async throws to propagate async - Bridge into async in GetWorkflowOperation via Task {}; completion() is called inside the Task after CDN fetch and decoding complete - Update WorkflowDetailProcessorTests to async throws with await Made-with: Cursor * Make CDN fetcher and processor completion-handler based for consistency Avoids Task{} in GetWorkflowOperation and keeps all operations calling completion() synchronously from within the HTTP callback, matching every other operation in the codebase. - WorkflowCdnFetcher.fetchCompiledWorkflowData now takes a completion handler; DirectWorkflowCdnFetcher uses URLSession.dataTask (non-blocking, no semaphore) - WorkflowDetailProcessor.process now takes a completion handler; inline action completes synchronously, use_cdn fans out to the fetcher callback - GetWorkflowOperation splits into getWorkflow/handleResponse/backendResult/ distribute helpers to stay within line-length limits - WorkflowDetailProcessorTests updated to use waitUntilValue pattern Made-with: Cursor * Fix ambiguous cache key delimiter in GetWorkflowOperation Space-separated appUserID+workflowId could collide (e.g. user 'a b' + workflow 'c' == user 'a' + workflow 'b c'). Use newline as delimiter, matching the precedent set by GetWebBillingProductsOperation. Made-with: Cursor * PR comments * remove cdn fetcher * fix response in BackendGetWorkflowsTests.swift * fix WorkflowResponseTests * fix error * [skip ci] Generating new test snapshots (#6584) * [skip ci] Generating new test snapshots (#6585) * [skip ci] Generating new test snapshots (#6586) * [skip ci] Generating new test snapshots (#6587) * [skip ci] Generating new test snapshots (#6588) * [skip ci] Generating new test snapshots (#6589) * [skip ci] Generating new test snapshots (#6590) * Test CDN mock is not re-assignable per test * [skip ci] Generating new test snapshots (#6597) * [skip ci] Generating new test snapshots (#6598) * [skip ci] Generating new test snapshots (#6599) * [skip ci] Generating new test snapshots (#6600) * [skip ci] Generating new test snapshots (#6601) * [skip ci] Generating new test snapshots (#6602) * [skip ci] Generating new test snapshots (#6603) * getWorkflow is signed * add type parameter * add response verification for CDN response * remove workflows list * add value to WorkflowTriggerAction * step id * hash and filerepo * missingCdnHash * linter and project * fix project * revert Package.resolved * @unchecked Sendable * use hash in generateOrGetCachedFileURL * fix compilation * skip responseVerificationMode in cdn * change basePath of caches * Add workflow-based paywall resolution (iOS parity with Android #3350) Implements the workflows endpoint so paywalls can be resolved from a workflow document rather than the offerings cache, gated behind the ENABLE_WORKFLOWS_ENDPOINT compile flag. Validates the step→screen→ offeringId chain and maps the workflow screen into Offering.PaywallComponents using the existing V2 rendering path. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Add tests for workflow networking layer and WorkflowScreenMapper BackendGetWorkflowTests covers HTTP call, request caching, per-user and per-workflow-ID deduplication, error propagation, empty appUserID, and full response decoding. WorkflowScreenMapperTests verifies all PaywallComponentsData fields and the UIConfig are mapped correctly. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * remove reserialization * Use explicit type in GetWorkflowOperation.createFactory for greppability Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Use explicit type in GetWorkflowOperation.createFactory for greppability Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix lint errors and refactor workflow offering resolution into PaywallViewConfiguration - Add swiftlint:disable comments for _revision identifier name in WorkflowsResponse.swift - Add missing_docs disable in WorkflowsResponse.swift (all @_spi(Internal) types) - Add file_length disable in Offering.swift - Fix identifier name 'd' → 'doubleValue' in WorkflowDetailProcessorTests.swift - Move workflow offering resolution from PaywallView into PaywallViewConfiguration.resolveOfferingOrThrow() - Add cachedInitialOffering() to PaywallViewConfiguration.Content - Add PaywallViewConfigurationTests Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix swiftlint identifier_name violation in WorkflowDetailProcessorTests Rename short variable `d` to `doubleValue` in two `if case .double(let d)` patterns to satisfy the identifier_name rule requiring names >= 3 characters. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Inject PaywallPurchasesType into offering resolution; remove dead code - Add offerings(), cachedOfferings, and workflow() to PaywallPurchasesType so PaywallViewConfiguration.Content no longer calls Purchases.shared directly - Thread purchases through cachedInitialOffering/resolveOffering/resolveOfferingOrThrow and all private helpers; callers pass purchaseHandler.purchasesInstance - Expose purchasesInstance on PurchaseHandler for call sites - Add stubs to NotConfiguredPurchases, LoadingPaywallPurchases, and MockPurchases (MockPurchases gets offeringsBlock/workflowBlock for test injection) - Remove dead WorkflowDetailProcessingError.missingInlineData case - Replace manual JSONSerialization parseEnvelope with Data.asJSONDictionary() - Collapse double #if nesting into single compound condition - Remove WHAT doc comment from WorkflowDetailProcessor - Refactor resolveOffering() to delegate to resolveOfferingOrThrow() - Parallelize workflow + offerings fetches with async let Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Move offering resolution from Content to PurchaseHandler Resolving an offering requires a purchases instance, so it belongs on PurchaseHandler rather than as a method on the Content enum that takes purchases as a parameter. purchasesInstance accessor removed. Also fix MockPurchases.map() to forward offeringsBlock, workflowBlock, and cachedOfferings to the mapped instance, and simplify PaywallViewConfigurationTests to use MockPurchases directly instead of configuring a real Purchases singleton. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Add clarifying comment to workflow(forOfferingIdentifier:) explaining parameter naming Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix CI failures: remove duplicate switch cases and add @_spi(Internal) to test imports - Remove duplicate .getWorkflow cases in HTTPRequestPath.swift switches (auto-merge artifact that caused duplicate_conditions lint violations) - Add @_spi(Internal) to @testable imports in three unit test files so they can access WorkflowFetchResult, PublishedWorkflow, and WorkflowStep which are now @_spi(Internal) public Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix @_spi access in WorkflowDetailProcessorTests WorkflowResponseTests already had @_spi(Internal) @testable import RevenueCat; WorkflowDetailProcessorTests was missing the @_spi annotation, causing 'id', 'steps', and 'initialStepId' to be inaccessible on PublishedWorkflow. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix MockWorkflowsAPI.swift missing from StoreKitUnitTests and UnitTests targets The PBXBuildFile entries for DB7EA77F2F9764F700BCC082 and DB7EA7802F9764F700BCC082 were referenced in the StoreKitUnitTests and UnitTests source phases but never defined, causing MockWorkflowsAPI to be missing at compile time and MockBackend.swift to fail with 'cannot find MockWorkflowsAPI in scope'. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Add offering_identifier field to WorkflowScreen for parity with Android Android PR #3350 split offering_id (internal ID) from offering_identifier (human-readable identifier used for lookup). iOS was only decoding offering_id and using it for the offering lookup, which would fail if the workflow response sends the public identifier in offering_identifier. - Add offeringIdentifier (from offering_identifier) to WorkflowScreen - Update PurchaseHandler to prefer offeringIdentifier, falling back to offeringId - Fix typo: workflow.screelns -> workflow.screens - Add decode tests for both offering fields Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix WorkflowResponseTests: add missing padding field to stack component fixtures Stack component decoding requires a padding field; test JSON fixtures were missing it causing keyNotFound decode errors. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix WorkflowResponseTests: add missing margin field to stack component fixtures Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Replace ENABLE_WORKFLOWS_ENDPOINT compile flag with runtime argument check The -EnableWorkflowsEndpoint launch argument replaces the compile-time flag, allowing workflow resolution to be toggled without rebuilding. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Add Workflow presentation mode to PaywallsTester Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Remove offeringId shim, allow no-paywall offerings to open as workflows - Drop WorkflowScreen.offeringId / offering_id CodingKey (Android already uses offering_identifier exclusively — aligns both platforms) - Simplify PurchaseHandler to use screen.offeringIdentifier directly - No-paywall offerings in PaywallsTester now render as tappable rows (workflow mode) instead of plain read-only text, with a context menu restricted to the Workflow option Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Rename WorkflowFetchResult to WorkflowDataResult, extract ProcessInfo extension Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix project --------- Co-authored-by: Cesar de la Vega <664544+vegaro@users.noreply.github.com> Co-authored-by: RevenueCat Git Bot <72824662+RCGitBot@users.noreply.github.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> |
||
|
|
abad547968 |
AdMob SSV: add @_spi(Internal) poll endpoint on Purchases (#6641)
* Add AdMob SSV poll endpoint and `pollAdMobSSVStatus` SPI * Trim verbose AdMob SSV transport comments. Align AdsAPI and cache-key inline documentation with existing repository comment density while preserving behavior. * Trim pollAdMobSSVStatus SPI documentation. Reduce prescriptive guidance in the internal API comment while keeping the key cancellation behavior note. * [skip ci] Generating new test snapshots (#6648) * [skip ci] Generating new test snapshots (#6647) * [skip ci] Generating new test snapshots (#6646) * [skip ci] Generating new test snapshots (#6645) * [skip ci] Generating new test snapshots (#6644) * [skip ci] Generating new test snapshots (#6642) * [skip ci] Generating new test snapshots (#6643) * Trigger CI for PR 6641 checks. Add a non-skip commit so required CircleCI statuses are reported on the current head. * Validate non-empty client transaction ID for AdMob SSV polling. Guard against empty client transaction IDs in the SPI and backend operation and add focused backend tests for the new error path. * [skip ci] Generating new test snapshots (#6652) * [skip ci] Generating new test snapshots (#6653) * Trigger CI for PR 6641 checks. Add a non-skip commit so required CircleCI statuses are reported on the current head. * Enable signature verification for AdMob SSV status path. Update HTTP request path tests to cover AdMob SSV signature and nonce behavior. * fix: address review — move AdMob SPI extension to dedicated file * fix: address review — fail fast when AdMob status mock is unstubbed * fix: address review — dedupe client transaction validation in backend operation * fix: address review — use enum for AdMob SSV poll status * fix: address review — allow extracted Purchases extensions to access backend * fix: address review — log unknown AdMob SSV statuses * fix: address review — add Purchases AdMob SSV mapping tests * fix: address review — map missing client transaction ID to configuration error * fix: address review — keep backend private by co-locating AdMob extension * fix: address review — remove unused raw-value conformance * fix: address review — assert warning log for unknown AdMob status * fix: address review — surface unknown AdMob SSV status to adapters * fix: address review — enable eTag support for AdMob SSV status path --------- Co-authored-by: RevenueCat Git Bot <72824662+RCGitBot@users.noreply.github.com> |
||
|
|
0b65086ec2 |
Add workflows network layer for multipage paywalls (#6557)
* Add workflows network layer for multipage paywalls - Add `getWorkflows` and `getWorkflow` endpoint paths - Add `WorkflowsListResponse` and `PublishedWorkflow` response models - Add `WorkflowDetailProcessor` to handle `inline`/`use_cdn` response actions - Add `WorkflowCdnFetcher` protocol and `DirectWorkflowCdnFetcher` implementation - Add `GetWorkflowsOperation` and `GetWorkflowOperation` cacheable network operations - Add `WorkflowsAPI` facade and wire into `Backend` - Add unit tests for all new components iOS equivalent of https://github.com/RevenueCat/purchases-android/pull/3300 Made-with: Cursor * fix xcodeproj * reset Package.resolved * Update WorkflowStep models to match actual backend response - Add WorkflowTrigger struct (name, type, action_id, component_id) - Add triggers, outputs, and metadata fields to WorkflowStep - Add metadata field to PublishedWorkflow - Remove value field from WorkflowTriggerAction (backend uses step_id only) Made-with: Cursor * Update WorkflowResponseTests to match updated models - Replace value/resolvedTargetStepId assertions with stepId - Remove testDecodeWorkflowTriggerActionValueTakesPrecedence (value field removed) - Add testDecodeWorkflowTrigger for new WorkflowTrigger struct - Add testDecodePublishedWorkflowWithMetadata - Update testDecodeWorkflowStepDefaults to cover triggers, outputs, metadata - Add testDecodeWorkflowStepMatchingActualBackendResponse with real backend payload Made-with: Cursor * Disable file_length lint rule in HTTPRequestPath.swift Made-with: Cursor * Fix GetWorkflowOperation to compute result once before distributing to callbacks CDN fetch and JSON decoding were running once per deduplicated callback. Compute the Result<WorkflowFetchResult, BackendError> once outside the performOnAllItemsAndRemoveFromCache loop, matching the pattern used by GetOfferingsOperation and other operations in the codebase. Made-with: Cursor * Fix CDN fetcher: use URLSession instead of Data(contentsOf:), classify errors correctly - Replace Data(contentsOf:) with URLSession.shared.dataTask + DispatchSemaphore in DirectWorkflowCdnFetcher; gets URLSession timeout, HTTP status validation, and proper network stack semantics - Add WorkflowDetailProcessingError.cdnFetchFailed typed error so CDN I/O failures are distinguishable from envelope parsing failures - Catch cdnFetchFailed in GetWorkflowOperation and map to NetworkError.networkError instead of NetworkError.decoding, fixing misleading error classification - Update WorkflowDetailProcessorTests to assert the typed error is thrown Made-with: Cursor * Replace semaphore CDN fetch with async/await using withCheckedThrowingContinuation - Make WorkflowCdnFetcher.fetchCompiledWorkflowData async throws; use withCheckedThrowingContinuation to bridge URLSession.dataTask into async, avoiding any thread-blocking - Make WorkflowDetailProcessor.process async throws to propagate async - Bridge into async in GetWorkflowOperation via Task {}; completion() is called inside the Task after CDN fetch and decoding complete - Update WorkflowDetailProcessorTests to async throws with await Made-with: Cursor * Make CDN fetcher and processor completion-handler based for consistency Avoids Task{} in GetWorkflowOperation and keeps all operations calling completion() synchronously from within the HTTP callback, matching every other operation in the codebase. - WorkflowCdnFetcher.fetchCompiledWorkflowData now takes a completion handler; DirectWorkflowCdnFetcher uses URLSession.dataTask (non-blocking, no semaphore) - WorkflowDetailProcessor.process now takes a completion handler; inline action completes synchronously, use_cdn fans out to the fetcher callback - GetWorkflowOperation splits into getWorkflow/handleResponse/backendResult/ distribute helpers to stay within line-length limits - WorkflowDetailProcessorTests updated to use waitUntilValue pattern Made-with: Cursor * Fix ambiguous cache key delimiter in GetWorkflowOperation Space-separated appUserID+workflowId could collide (e.g. user 'a b' + workflow 'c' == user 'a' + workflow 'b c'). Use newline as delimiter, matching the precedent set by GetWebBillingProductsOperation. Made-with: Cursor * PR comments * remove cdn fetcher * fix response in BackendGetWorkflowsTests.swift * fix WorkflowResponseTests * fix error * [skip ci] Generating new test snapshots (#6584) * [skip ci] Generating new test snapshots (#6585) * [skip ci] Generating new test snapshots (#6586) * [skip ci] Generating new test snapshots (#6587) * [skip ci] Generating new test snapshots (#6588) * [skip ci] Generating new test snapshots (#6589) * [skip ci] Generating new test snapshots (#6590) * Test CDN mock is not re-assignable per test * [skip ci] Generating new test snapshots (#6597) * [skip ci] Generating new test snapshots (#6598) * [skip ci] Generating new test snapshots (#6599) * [skip ci] Generating new test snapshots (#6600) * [skip ci] Generating new test snapshots (#6601) * [skip ci] Generating new test snapshots (#6602) * [skip ci] Generating new test snapshots (#6603) * getWorkflow is signed * add type parameter * add response verification for CDN response * remove workflows list * add value to WorkflowTriggerAction * step id * hash and filerepo * missingCdnHash * linter and project * fix project * revert Package.resolved * @unchecked Sendable * use hash in generateOrGetCachedFileURL * fix compilation * skip responseVerificationMode in cdn * change basePath of caches * remove reserialization * Use explicit type in GetWorkflowOperation.createFactory for greppability Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix swiftlint identifier_name violation in WorkflowDetailProcessorTests Rename short variable `d` to `doubleValue` in two `if case .double(let d)` patterns to satisfy the identifier_name rule requiring names >= 3 characters. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * remove default FileRepositoryType * fix project * fix test * test preserves camel case * Generating new test snapshots for `feat/workflows-network-layer` - ios-15 (#6654) * Generating new test snapshots for `feat/workflows-network-layer` - ios-14 (#6656) --------- Co-authored-by: RevenueCat Git Bot <72824662+RCGitBot@users.noreply.github.com> Co-authored-by: Facundo Menzella <facumenzella@gmail.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Facundo Menzella <facumenzella@users.noreply.github.com> |
||
|
|
a59ca8f4ea |
Expose apiKey on Purchases via @_spi(Internal) (#6635)
Adapter modules that import the SDK with `@_spi(Internal) import RevenueCat` (e.g. `RevenueCatAdMob`) need to read the configured public API key when constructing payloads such as the AdMob SSV `customRewardString`. - Store the API key on `SystemInfo` and wire it through `Purchases` init. - Add `@_spi(Internal) public var apiKey: String` on `Purchases`. - Add a Swift API tester case so any future signature change is caught. - Add a unit test verifying the accessor returns the configured key. The key is already public plaintext in the app binary, so exposing it as SPI does not introduce any new security risk. |
||
|
|
f1d9fb86bb |
Send X-Installation-Method header in all API requests (#6431)
* Send X-Installation-Method header in all API requests Detect at compile time which package manager was used to integrate the SDK (SPM, CocoaPods, or prebuilt XCFramework) and send it as a new X-Installation-Method header on every HTTP request. This helps the backend understand how customers integrate the SDK. Made-with: Cursor * Add installation method assertions to installation tests Expose installationMethod via @_spi(Internal) on Purchases and verify the correct value in each installation test project (SPM, CocoaPods, Carthage, Xcode direct, and XCFramework). Made-with: Cursor * [skip ci] Generating new test snapshots (#6433) * [skip ci] Generating new test snapshots (#6435) * [skip ci] Generating new test snapshots (#6434) * [skip ci] Generating new test snapshots (#6440) * [skip ci] Generating new test snapshots (#6436) * [skip ci] Generating new test snapshots (#6437) * [skip ci] Generating new test snapshots (#6438) * [skip ci] Generating new test snapshots (#6439) * [skip ci] Generating new test snapshots (#6443) * Fix missing COCOAPODS_INSTALLATION flag on CocoaPods test targets The flag was only set on the app target, not on the test targets, so the #if COCOAPODS_INSTALLATION branches in InstallationTests.swift were never compiled for CocoaPods tests. Add OTHER_SWIFT_FLAGS with -DCOCOAPODS_INSTALLATION to all four CocoaPods test target configs. Made-with: Cursor --------- Co-authored-by: RevenueCat Git Bot <72824662+RCGitBot@users.noreply.github.com> |
||
|
|
73c5534bf7 | Fix reduced timeouts being used for HTTP requests when a proxy URL is configured (#6416) | ||
|
|
9356db1d42 | [CEC Mode]: introduce isPurchaseAllowedByRestoreBehavior() (#6192) | ||
|
|
5f9143563c |
Improve accuracy of transactions origin Part 8: sync cached local transaction metadata (#6073)
* Small cleanup * add `LocalTransactionDetailsStorage` * Add models for local transaction metadata caching. Reset PurchasesOrchestrator * Complete `Codable`, lossless implementation of `LocalTransactionMetadata` * Rename `LocalTransactionMetadataCache` to `LocalTransactionMetadataStore` * Comment * lint * Remove unnecessary import * Remove unused method * Replace `Storefront` with `storeCountry` in both `ProductRequestData` and `PurchasedTransactionData` This removes the need of a `StorefrontEncodedWrapper` object * Disambiguate `asDictionary()` method Now that `SubscriberAttribute` is `Codable`, `Encodable`'s `asDictionary()` and `SubscriberAttribute`'s `asDictionary()` were clashing * Undo `Codable` conformance of `PresentedOfferingContext` and `TargetingContext` * lint * `LocalTransactionMetadata` now refers to a single transaction. `LocalTransactionMetadataStore` now stores one file per transaction and does not use an in-memory cache * Temporarily remove `LocalTransactionMetadata` and `LocalTransactionMetadataStore` for easier PR review * Revert "Temporarily remove `LocalTransactionMetadata` and `LocalTransactionMetadataStore` for easier PR review" This reverts commit |
||
|
|
a01af9117b |
Added a swiftlint rule that disallows direct use of storage directory URL related APIs (#6113)
* Renamed applicationSupport directory type to persistence * Only deleting the old diagnostics file when the new diagnostics file does not exist yet * Implemented PR feedback * Changed testing-only check from assertion to #if DEBUG * Implemented migration + deleting LargeItemCache files from the Documents directory for Etags + DeviceCache caching * Fixed compile issue after rebase * Fixed test compilation after rebase * Updated test * Refactored logica to the specific classes that actually store the data, removing the logic from the large item cache * Fix compilation issue * Moved comment * Implemented PR feedback * Implemented most PR feedback * Implemented PR feedback * Simplified ETagManager's old Documents directory removal by just doing it on init if needed * Added a swiftlint rule that disallows direct use of storage directory URL related APIs * Fix compilation |