diff --git a/AGENTS.md b/AGENTS.md index efc3aa2..9b2051d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,6 +6,6 @@ Use the canonical Purchasely skills instead of duplicating SDK guidance in this - Integration from scratch or step-by-step: @./skills/purchasely-integrate/SKILL.md - Review an existing integration: @./skills/purchasely-review/SKILL.md - Debug a runtime issue: @./skills/purchasely-debug/SKILL.md -- Migrate native iOS, native Android, or Flutter from SDK v5 to v6: @./skills/purchasely-migrate/SKILL.md +- Migrate native iOS, native Android, Flutter, React Native, or Cordova from SDK v5 to v6: @./skills/purchasely-migrate/SKILL.md Platform-specific guides, concept references, and troubleshooting recipes live under `references/`. The skills link to the exact files they need; consult those references on demand rather than preloading or duplicating them here. diff --git a/CHANGELOG.md b/CHANGELOG.md index b731d4c..d296738 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,30 @@ All notable changes to this project are documented here. The format is based on - `references/concepts/dynamic-offerings.md` — covers `setDynamicOffering` across platforms, server-side application at fetch time, one-plan-one-billing-type pitfall. - `references/concepts/monthly-commitment.md` — covers Apple 12-month advance commitment (iOS 26.4+), `PLYBillingPlanType`, eligibility rules. +- `references/concepts/rendering-engine.md` — UIKit/Android Views rendering engines, image cache, Lottie bridge, known rendering bugs. +- `references/concepts/web-checkout.md` — Stripe payment links, `WEB_CHECKOUT_*` events. +- New debug known-issues: Lottie silent-nothing, stuck spinner after purchase cancel, Flutter `display()` hang, Indonesian locale pre-6.0.1, `PRESENTATION_VIEWED` eviction pre-rc.3, iPad campaign-close freeze, iOS 18.4/18.5 DEBUG image-cache bypass, video autoplay bug. +- New review checks: stale rc-era `interceptAction` import, Android `close()`-closes-all semantics, `oneSignalPlayerId` removal, `allowCampaigns` default flip. +- Android v6 additions documented: zero-code automatic deeplink handling, `themeMode()` at init, structured `PLYTransitionDimension`, bundled lint checks. +- Release process section in `CLAUDE.md`. + +### Changed + +- **SDK pins updated to GA** across all skills and references: native iOS **6.0.0** (SPM install now primary), Android **6.0.1**, Flutter **6.0.0** (stable on pub.dev), React Native **6.0.0-rc.3**, Cordova **6.0.0-rc.3**. +- Android toolchain updated (Kotlin 2.3.x). +- Action-interceptor guidance updated: returning success on purchase/restore in Observer mode auto-synchronizes — no manual `synchronize()` inside the interceptor. +- Expanded `references/concepts/monthly-commitment.md` — added Google Play native installment subscriptions, cross-platform scope (SDK 6.0+ on iOS/Flutter/RN/Cordova) for the Apple advance-commitment fields, `INSTALLMENT_PAID` / `INSTALLMENT_REFUNDED` webhooks, `commitment_*` attributes. + +### Fixed + +- React Native `allowDeeplink` default corrected (native default `true`, no RN exception). +- Removed-API names corrected per platform: iOS `showController` / `PLYUIControllerType` (`presentSubscriptions()` never existed on iOS); Android `subscriptionsFragment()`; `displaySubscriptionCancellationInstruction()` is removed, not a no-op. +- Android default dismiss handler name corrected (`setDefaultPresentationDismissHandler`). +- Native iOS requirement corrected to 13.4+ (inherited from 5.x). +- Flutter concept pages updated to `PLY`-prefixed Dart types. +- Dead anchor in `debug-mode.md`. +- Migrate-skill platform coverage now lists all 5 platforms in README, AGENTS.md, GEMINI.md, and the hooks intro. +- `gemini-extension.json` version aligned with the other manifests. ## [2.0.0-rc.6] — 2026-07-07 diff --git a/CLAUDE.md b/CLAUDE.md index a73f1f7..8df2be4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,11 +8,31 @@ Every PR that adds, changes, removes, or deprecates anything user-visible **must 1. Add the entry under the top-level `## [Unreleased]` section, in the appropriate sub-section: `Added`, `Changed`, `Removed`, `Deprecated`, `Fixed`, `Security`. 2. Keep entries short, factual, and user-facing. Example: `Added references/concepts/promotional-offers.md — covers Apple promo offers, Google developer-determined offers, offer codes` — not `Reworked the references directory`. -3. When cutting a release, rename `[Unreleased]` to `[X.Y.Z] — YYYY-MM-DD`, bump `version` in `.claude-plugin/plugin.json` + `package.json`, then add a fresh empty `[Unreleased]` section at the top. +3. When cutting a release, rename `[Unreleased]` to `[X.Y.Z] — YYYY-MM-DD` and add a fresh empty `[Unreleased]` section at the top — see **Release process** below for the full, authoritative list of manifests to bump. 4. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning follows [SemVer](https://semver.org/spec/v2.0.0.html). **Rule of thumb:** if you wouldn't write the change in the release notes a client reads, you probably don't need a CHANGELOG entry. Otherwise, write one. +## Release process + +Releases are cut **directly on `main`** — there is no release PR. + +1. In `CHANGELOG.md`, rename `[Unreleased]` to `[X.Y.Z] — YYYY-MM-DD` and add a fresh empty `[Unreleased]` section above it. +2. Bump `version` in **every** manifest — this list is exhaustive; `gemini-extension.json` was missed in earlier releases and stayed at `1.1.0` until this PR caught it up: + - `.claude-plugin/plugin.json` + - `.claude-plugin/marketplace.json` + - `.cursor-plugin/plugin.json` + - `.cursor-plugin/marketplace.json` + - `purchasely/.claude-plugin/plugin.json` + - `purchasely/.cursor-plugin/plugin.json` + - `purchasely/.codex-plugin/plugin.json` + - `package.json` + - `gemini-extension.json` +3. Commit directly on `main`: `chore(release): X.Y.Z`. +4. Tag and release: `git tag X.Y.Z && gh release create X.Y.Z`. Tags are **bare SemVer, no `v` prefix** (confirmed via `git tag --sort=-creatordate`, e.g. `2.0.0-rc.6`, `2.0.0-rc.5`, …) — keep using that format. +5. **Versioning**: never bump a major version without explicit sign-off from the user; default to a minor (or patch) bump. +6. If the release changed a `name:` or `description:` field in any `SKILL.md` frontmatter, trigger the agentskill.sh re-scan described below. + ## Wrapper pattern: name and scope The "wrapper" pattern (a single dedicated class that owns every call into the Purchasely SDK) is a **recommendation**, not a requirement. The Purchasely SDK is fully usable when called directly from ViewModels, UI code, or anywhere else. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ed52ce2..1cde2ef 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -33,7 +33,7 @@ Thanks for helping make Purchasely easier to integrate. This guide covers how to - Keep examples **runnable**. If a snippet references an API, the API must exist in the current public SDK. - Use **placeholders** (`YOUR_API_KEY`, `PLACEMENT_ID`) — never commit real keys. -- Prefer **direct SDK calls** in examples (`Purchasely.fetchPresentation(...)`). The wrapper pattern is recommended but optional — see `CLAUDE.md` for the full rule. +- Prefer **direct SDK calls** in examples (`Purchasely.setUserAttribute(...)`). The wrapper pattern is recommended but optional — see `CLAUDE.md` for the full rule. - One concept per file when possible; cross-link with relative paths. - Markdown headings: `##` for sections, `###` for subsections; no `#` (reserved for the document title). diff --git a/GEMINI.md b/GEMINI.md index f0c23e2..bcda230 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -6,7 +6,7 @@ Use the canonical Purchasely skills instead of duplicating SDK guidance in this - Integration from scratch or step-by-step: @./skills/purchasely-integrate/SKILL.md - Review an existing integration: @./skills/purchasely-review/SKILL.md - Debug a runtime issue: @./skills/purchasely-debug/SKILL.md -- Migrate native iOS, native Android, or Flutter from SDK v5 to v6: @./skills/purchasely-migrate/SKILL.md +- Migrate native iOS, native Android, Flutter, React Native, or Cordova from SDK v5 to v6: @./skills/purchasely-migrate/SKILL.md ## References diff --git a/README.md b/README.md index d994264..5a51754 100644 --- a/README.md +++ b/README.md @@ -155,7 +155,7 @@ Tools that read the repository-level `AGENTS.md` should use this repository dire | `/purchasely:integrate` | Step-by-step SDK integration from scratch — installation, initialization, paywall display, action interceptor, user management | | `/purchasely:review` | Automated checklist review of your existing integration — finds bugs, deprecated APIs, and missing best practices | | `/purchasely:debug` | Diagnostic trees for common issues — blank paywalls, frozen UI, purchase failures, deeplink problems | -| `/purchasely:migrate` | Upgrade an existing native iOS, native Android, or Flutter integration from SDK v5 to v6 | +| `/purchasely:migrate` | Upgrade an existing native iOS, native Android, Flutter, React Native, or Cordova integration from SDK v5 to v6 | ## Usage Examples @@ -265,18 +265,18 @@ Purchasely-AI-Plugin/ | `/purchasely:integrate` | Slash command + matching `purchasely-integrate` skill | The command launches the skill; the skill is also auto-invoked when Claude detects an SDK integration task | | `/purchasely:review` | Slash command + matching `purchasely-review` skill | Same as above | | `/purchasely:debug` | Slash command + matching `purchasely-debug` skill | Same as above | -| `/purchasely:migrate` | Slash command + matching `purchasely-migrate` skill | Migrates native iOS, native Android, Flutter, and React Native integrations from SDK v5 to v6 | +| `/purchasely:migrate` | Slash command + matching `purchasely-migrate` skill | Migrates native iOS, native Android, Flutter, React Native, and Cordova integrations from SDK v5 to v6 | | Natural Purchasely SDK question | Portable `purchasely-sdk-expert` skill + Claude Code `purchasely-sdk-expert` agent when available | No slash command needed — ask normally and the expert guidance can be used directly for free-form Purchasely SDK Q&A | ## Supported Platforms | Platform | SDK line | Init | Paywalls | Interceptor | Deeplinks | User Mgmt | |----------|----------|------|----------|-------------|-----------|-----------| -| iOS (Swift / Obj-C) | v6 (`6.0.0-rc.1`) | `Purchasely.apiKey(...).runningMode(...).start()` | `PLYPresentationBuilder...build().preload()` → `display(from:)` | per-action `interceptAction` returning `PLYInterceptResult` | `handleDeeplink` / `allowDeeplink` | `userLogin` / `userLogout` | -| Android (Kotlin / Java) | v6 (`6.0.0-rc.1`) | `Purchasely { ... }` or `Purchasely.Builder(...)` | `PLYPresentation { ... }.preload()` → `display(context)` | per-action `interceptAction` returning `PLYInterceptResult` | auto-intercept + `handleDeeplink` / `allowDeeplink` | `userLogin` / `userLogout` | -| Flutter | v6 (`6.0.0-rc.1`) | `PurchaselyBuilder.apiKey(...).start()` | `PresentationBuilder...build()` → `preload()` / `display(...)` | per-action `interceptAction` returning `InterceptResult` | `handleDeeplink` / `allowDeeplink` | `userLogin` / `userLogout` | -| React Native | v6 (`6.0.0-rc.2`) | `Purchasely.builder(...).runningMode(...).start()` | `Purchasely.presentation.placement(...).build()` → `preload()` / `display(transition?)` | per-action `interceptAction` returning `'success' \| 'failed' \| 'notHandled'` | `handleDeeplink` / `allowDeeplink` | `userLogin` / `userLogout` | -| Cordova | v6 (`6.0.0-rc.1`) | `Purchasely.start(options, success, error)` | `fetchPresentationForPlacement` + `presentPresentation` (display-mode arg) | per-action `interceptAction` returning `InterceptResult` | `handleDeeplink` / `allowDeeplink` | `userLogin` / `userLogout` | +| iOS (Swift / Obj-C) | v6 (`6.0.0`, GA) | `Purchasely.apiKey(...).runningMode(...).start()` | `PLYPresentationBuilder...build().preload()` → `display(from:)` | per-action `interceptAction` returning `PLYInterceptResult` | `handleDeeplink` / `allowDeeplink` | `userLogin` / `userLogout` | +| Android (Kotlin / Java) | v6 (`6.0.1`, GA) | `Purchasely { ... }` or `Purchasely.Builder(...)` | `PLYPresentation { ... }.preload()` → `display(context)` | per-action `interceptAction` returning `PLYInterceptResult` | auto-intercept + `handleDeeplink` / `allowDeeplink` | `userLogin` / `userLogout` | +| Flutter | v6 (`6.0.0`, stable on pub.dev) | `PurchaselyBuilder.apiKey(...).start()` | `PresentationBuilder...build()` → `preload()` / `display(...)` | per-action `interceptAction` returning `InterceptResult` | `handleDeeplink` / `allowDeeplink` | `userLogin` / `userLogout` | +| React Native | v6 (`6.0.0-rc.3`) | `Purchasely.builder(...).runningMode(...).start()` | `Purchasely.presentation.placement(...).build()` → `preload()` / `display(transition?)` | per-action `interceptAction` returning `'success' \| 'failed' \| 'notHandled'` | `handleDeeplink` / `allowDeeplink` | `userLogin` / `userLogout` | +| Cordova | v6 (`6.0.0-rc.3`) | `Purchasely.start(options, success, error)` | `fetchPresentationForPlacement` + `presentPresentation` (display-mode arg) | per-action `interceptAction` returning `InterceptResult` | `handleDeeplink` / `allowDeeplink` | `userLogin` / `userLogout` | ## Requirements @@ -318,7 +318,16 @@ When a new SDK version is released: 1. **Update `purchasely/references/sdk-versions.md`** — single source of truth for pinned versions. 2. Update version references in `purchasely/skills/purchasely-integrate/SKILL.md` and each platform's `purchasely/references//`. 3. Update `purchasely/references/` with new/changed APIs. -4. Bump `version` in `.claude-plugin/plugin.json`, `purchasely/.claude-plugin/plugin.json`, `purchasely/.codex-plugin/plugin.json`, and `package.json`. +4. Bump `version` in **every** manifest: + - `.claude-plugin/plugin.json` + - `.claude-plugin/marketplace.json` + - `.cursor-plugin/plugin.json` + - `.cursor-plugin/marketplace.json` + - `purchasely/.claude-plugin/plugin.json` + - `purchasely/.cursor-plugin/plugin.json` + - `purchasely/.codex-plugin/plugin.json` + - `package.json` + - `gemini-extension.json` 5. Add an entry to [CHANGELOG.md](CHANGELOG.md). 6. Tag and release. diff --git a/gemini-extension.json b/gemini-extension.json index 080c9db..ff588b0 100644 --- a/gemini-extension.json +++ b/gemini-extension.json @@ -1,6 +1,6 @@ { "name": "purchasely", "description": "Purchasely SDK integration assistant — guides implementation, reviews code, and debugs issues across iOS, Android, React Native, Flutter, and Cordova.", - "version": "1.1.0", + "version": "2.0.0-rc.6", "contextFileName": "GEMINI.md" } diff --git a/purchasely/commands/migrate.md b/purchasely/commands/migrate.md index 85ad9df..d88d622 100644 --- a/purchasely/commands/migrate.md +++ b/purchasely/commands/migrate.md @@ -1,6 +1,6 @@ --- description: "Migrate an existing Purchasely SDK integration to a newer SDK major version" -argument-hint: "[platform: android|ios] [from:5.x] [to:6.0.0-rc.1]" +argument-hint: "[platform: android|ios|flutter|react-native|cordova] [from:5.x] [to:6.x]" --- # Purchasely SDK Migration diff --git a/purchasely/hooks/intro.md b/purchasely/hooks/intro.md index 9d2cef2..9ff434f 100644 --- a/purchasely/hooks/intro.md +++ b/purchasely/hooks/intro.md @@ -8,7 +8,7 @@ This project is using the **Purchasely AI Plugin**. You have access to five auto - **`purchasely-integrate`** — step-by-step SDK integration: install, `Purchasely.start(...)`, paywall display, action interceptor, user login/logout, Restore, Manage Subscription, plus campaigns / promo offers / analytics. - **`purchasely-review`** — checklist review that audits an existing integration for missing interceptor completions, deprecated APIs, identity ordering, `PrivacyInfo.xcprivacy`, Google Play Billing v8, log-level gating, and more. - **`purchasely-debug`** — diagnostic flow for blank paywalls, frozen UI, purchase failures, and deeplinks. Includes SDK debug logging, `PLYError` decoding, and the screen-issue-report escalation template. -- **`purchasely-migrate`** — v5 → v6 migration for native iOS, native Android, and Flutter integrations. +- **`purchasely-migrate`** — v5 → v6 migration for native iOS, native Android, Flutter, React Native, and Cordova integrations. ## Expert agent diff --git a/purchasely/references/android/api-reference.md b/purchasely/references/android/api-reference.md index eab83ca..46751e1 100644 --- a/purchasely/references/android/api-reference.md +++ b/purchasely/references/android/api-reference.md @@ -24,6 +24,8 @@ import io.purchasely.ext.presentation.display import io.purchasely.ext.presentation.buildView import io.purchasely.ext.presentation.getFragment // or simply: import io.purchasely.ext.presentation.* + +import io.purchasely.views.presentation.PLYThemeMode ``` ## Initialization @@ -37,6 +39,7 @@ Purchasely { userId("user-123") // optional stores(listOf(GoogleStore())) runningMode(PLYRunningMode.Full) // default is Observer — set Full for purchase handling/validation + themeMode(PLYThemeMode.SYSTEM) // optional, since 6.0.1 — SYSTEM/LIGHT/DARK (parity with iOS) logLevel(LogLevel.DEBUG) logcatEnabled(true) // optional, controls Logcat output independently allowDeeplink(true) @@ -57,6 +60,7 @@ Purchasely.Builder(applicationContext) .userId("user-123") .stores(listOf(GoogleStore())) .runningMode(PLYRunningMode.Full) + .themeMode(PLYThemeMode.SYSTEM) // optional, since 6.0.1 — SYSTEM/LIGHT/DARK (parity with iOS) .logLevel(LogLevel.DEBUG) .logcatEnabled(true) .allowDeeplink(true) @@ -77,6 +81,7 @@ Purchasely.Builder(applicationContext) | `userId(id)` | Optional anonymous-to-known mapping. | | `stores(stores)` | Billing store implementations (e.g. `GoogleStore()`). Optional — storeless start is supported. | | `runningMode(mode)` | `PLYRunningMode.Full` or `PLYRunningMode.Observer`. **Default is `Observer`.** | +| `themeMode(mode)` | Since `6.0.1`, settable on **both** the DSL and the fluent Builder (parity with iOS `themeMode(_:)`). `mode` is a `PLYThemeMode` (`SYSTEM` / `LIGHT` / `DARK`); default `SYSTEM`. | | `logLevel(level)` | `LogLevel.DEBUG` / `WARN` / `ERROR` / … | | `logcatEnabled(enabled)` | Controls Logcat output independently of `logLevel` (default `true`). | | `allowDeeplink(allowed)` | Enables deeplink-driven display. Default `true` in v6 (was `false`). | @@ -207,6 +212,15 @@ button.setOnClickListener { loaded?.display(this) } `PLYPresentation.id` was renamed `screenId`; `toMap()["id"]` is now `toMap()["screenId"]`. +### `PLYTransition` sizing — `heightPercentage` → `height` + +The old `heightPercentage: Float` field is **removed** (no alias). Sizing is now a structured `PLYTransitionDimension(type: PLYDimensionType, value: Float)`, supporting pixels **or** a percentage: + +```kotlin +val drawerHeight = PLYTransitionDimension(PLYDimensionType.PERCENTAGE, 0.6f) // 60% of screen height +val popinHeight = PLYTransitionDimension(PLYDimensionType.PIXEL, 480f) // fixed 480px +``` + ### `PLYPresentationState` Builder / prepared / loaded all expose `state: StateFlow`: @@ -379,6 +393,26 @@ Purchasely.handleDeeplink(uri, activity) // still works; deduped against auto-in `readyToOpenDeeplink` → `allowDeeplink`; `isDeeplinkHandled(uri, activity)` → `handleDeeplink(uri, activity)`. +## Presentation Dismiss Handler + +### `Purchasely.setDefaultPresentationDismissHandler(handler)` + +A default handler for paywalls you do **not** instantiate yourself — chiefly deeplink- and campaign-opened screens, where no `onDismissed` callback was supplied. The handler receives a single `PLYPresentationOutcome`. + +```kotlin +Purchasely.setDefaultPresentationDismissHandler { outcome -> + when (outcome.purchaseResult) { + PLYPurchaseResult.PURCHASED -> refreshAccess() + PLYPurchaseResult.RESTORED -> refreshAccess() + PLYPurchaseResult.CANCELLED, null -> Unit + } +} +``` + +Since `6.0.1` the `handler` parameter is **nullable** — pass `null` to unregister the default handler (equivalent to a dedicated `remove...` call). It is mutually exclusive with per-presentation callbacks (`onDismissed`, the `display()` completion, `PLYPresentationState.Dismissed`). + +> `setDefaultPresentationResultHandler` never existed on Android as a name — `setDefaultPresentationDismissHandler` is, and always was, the correct Android name (unlike iOS, which renamed *from* `setDefaultPresentationResultHandler`). + ## User Management ```kotlin @@ -406,6 +440,8 @@ Purchasely.userSubscriptionsHistory { subscriptions -> /* history */ } The built-in subscription list and cancellation survey UI (`subscriptionsFragment()`, all `PLYSubscriptions*` / `PLYSubscriptionDetail*` / `PLYSubscriptionCancellation*`) were removed — build your own UI from the data APIs above. `purchaseHistory()` → `userSubscriptionsHistory()` (suspend, backend); `isPastSubscriber()` → derive from history. +> `Purchasely.displaySubscriptionCancellationInstruction()` is also **removed** (dropped between `6.0.0-rc.2` and `6.0.0-rc.3`) — it is not mentioned in the upstream `MIGRATION_V6.md`, so this plugin is currently the only place documenting the removal. There is no Android API named `presentSubscriptions()` — if you see that name applied to Android, it's a cross-platform mix-up (RN/Cordova use that name, native Android does not). + ## Close Screens ```kotlin @@ -414,6 +450,8 @@ presentation.close() presentation.back() ``` +> `presentation.close()` **delegates to `Purchasely.closeAllScreens()`** — it closes every screen currently displayed, not just this presentation instance. There is no instance-scoped close API on Android (an upstream design choice); if a paywall is stacked over another Purchasely screen, calling `close()` on either one dismisses both. + ## Synchronize `synchronize()` refreshes the subscriptions cache before firing `onSuccess`. Both callbacks are optional (default `null`), so fire-and-forget still works. diff --git a/purchasely/references/android/common-patterns.md b/purchasely/references/android/common-patterns.md index 0c5b481..0f7443a 100644 --- a/purchasely/references/android/common-patterns.md +++ b/purchasely/references/android/common-patterns.md @@ -162,8 +162,7 @@ Purchasely.interceptAction { info, purchase -> } fun onBillingSuccess() { - Purchasely.synchronize() - pendingResult?.invoke(PLYInterceptResult.SUCCESS) + pendingResult?.invoke(PLYInterceptResult.SUCCESS) // resolving with SUCCESS auto-synchronizes — do not call Purchasely.synchronize() here pendingResult = null // Observer mode does not auto-close (the implicit close_all is Full-only). This handler // runs after the interceptor has resolved, so dismiss the paywall here — unless a @@ -194,8 +193,7 @@ Purchasely.interceptAction(PLYPresentationAction.Purchase.class, (info, action, purchase.getPlan().getStore_product_id(), purchase.getSubscriptionOffer() != null ? purchase.getSubscriptionOffer().getOfferToken() : null, billingResult -> { - Purchasely.synchronize(); - result.invoke(PLYInterceptResult.SUCCESS); + result.invoke(PLYInterceptResult.SUCCESS); // resolving with SUCCESS auto-synchronizes — do not call Purchasely.synchronize() here } ); }); @@ -209,7 +207,9 @@ View view = loaded.buildView(getApplicationContext(), outcome -> { /* handle */ container.addView(view); ``` -## Synchronize after a purchase (Observer mode) +## Synchronize for a purchase processed outside the interceptor + +Resolving a `.purchase` / `.restore` interceptor with `PLYInterceptResult.SUCCESS` already **auto-synchronizes** the receipt — do not also call `Purchasely.synchronize()` from inside the interceptor. Call it manually only for transactions your app processes **outside** the interceptor flow, e.g. a "Restore Purchases" button on a settings screen, or a client-side (`PLYPresentationType.CLIENT`) presentation with its own purchase button: ```kotlin Purchasely.synchronize( diff --git a/purchasely/references/android/initialization.md b/purchasely/references/android/initialization.md index 56bfa58..2d91890 100644 --- a/purchasely/references/android/initialization.md +++ b/purchasely/references/android/initialization.md @@ -6,29 +6,35 @@ Native Android SDK v6 initializes with the Kotlin DSL (`Purchasely { … }`, rec ```kotlin dependencies { - implementation("io.purchasely:core:6.0.0-rc.1") - implementation("io.purchasely:google-play:6.0.0-rc.1") // Google Play - implementation("io.purchasely:player:6.0.0-rc.1") // optional video support + implementation("io.purchasely:core:6.0.1") + implementation("io.purchasely:google-play:6.0.1") // Google Play + implementation("io.purchasely:player:6.0.1") // optional video support // alternative stores: - implementation("io.purchasely:huawei-services:6.0.0-rc.1") // Huawei AppGallery - implementation("io.purchasely:amazon:6.0.0-rc.1") // Amazon Appstore + implementation("io.purchasely:huawei-services:6.0.1") // Huawei AppGallery + implementation("io.purchasely:amazon:6.0.1") // Amazon Appstore } ``` -There is **no** `presentation-compose` artifact. For Compose embedding, wrap the Android `View` from `buildView(...)` in an `AndroidView` (see [common-patterns.md](common-patterns.md)). +`io.purchasely:core` is published as a **fat AAR** — internal modules (`:common`, `:network`, `:storage`, …) are fused into it. There are no separate Maven coordinates for them, and their `io.purchasely.*` FQNs are stable across the fusion. There is **no** `presentation-compose` artifact and no Compose composable today; a Compose renderer is in active development for a future release. For Compose embedding now, wrap the Android `View` from `buildView(...)` in an `AndroidView` (see [common-patterns.md](common-patterns.md)). + +`io.purchasely:core` also bundles a **lint.jar** (from an internal `:core-lint` module), so its checks run automatically for any consumer — no separate lint dependency to add. It flags: `context()` / `apiKey()` missing from the DSL/Builder, and `runningMode(PLYRunningMode.Full)` configured with no `stores(...)`. ## Toolchain | Requirement | Version | |-------------|---------| -| Gradle | 9.3.0+ | -| AGP | 9.x | -| Kotlin | 2.2.x (K2 compiler) | +| Gradle | ≥ 9.3 (floor; the SDK's own dev wrapper runs 9.6.1) | +| AGP | 9.0.1 | +| Kotlin | **2.3.21** (K2 compiler; fixes issues present in the 2.2.x line used by early v6 release candidates) | | JDK (to build) | 17 | | `minSdk` | 23 | | `compileSdk` | 36 | +| `targetSdk` | 35 | +| Google Play Billing | 8.3.0 (via `io.purchasely:google-play`) | + +The reified entry points `interceptAction { … }` / `removeActionInterceptor()` are `inline` **member functions of `Purchasely`** (since `6.0.0-rc.3`) targeting JVM 11 — no separate import beyond `io.purchasely.ext.Purchasely` is needed. Compile your Kotlin module with `jvmTarget = 11`, or use the `Class`-based overload. With AGP 9, remove the explicit `org.jetbrains.kotlin.android` plugin and the `android { kotlinOptions { … } }` block (AGP provides Android Kotlin support directly). -The reified entry points `interceptAction { … }` / `removeActionInterceptor()` are `inline` functions targeting JVM 11. Compile your Kotlin module with `jvmTarget = 11`, or use the `Class`-based overload. With AGP 9, remove the explicit `org.jetbrains.kotlin.android` plugin and the `android { kotlinOptions { … } }` block (AGP provides Android Kotlin support directly). +> Before `6.0.0-rc.3`, `interceptAction` / `removeActionInterceptor()` were **top-level extension functions** requiring `import io.purchasely.ext.interceptAction`. If you integrated against `rc.1` or `rc.2`, remove that now-dead import when you upgrade — the member-function form resolves without it. ## Default running mode is `Observer` ⚠️ @@ -51,6 +57,7 @@ class App : Application() { userId("user-123") // optional stores(listOf(GoogleStore())) runningMode(PLYRunningMode.Full) // default is Observer + themeMode(...) // optional, since 6.0.1 — system/light/dark (parity with iOS) logLevel(LogLevel.DEBUG) logcatEnabled(true) // optional, independent of logLevel allowDeeplink(true) @@ -77,6 +84,7 @@ Purchasely.Builder(applicationContext) .userId("user-123") .stores(listOf(GoogleStore())) .runningMode(PLYRunningMode.Full) + .themeMode(...) // optional, since 6.0.1 — system/light/dark (parity with iOS) .logLevel(LogLevel.DEBUG) .logcatEnabled(true) .allowDeeplink(true) @@ -90,7 +98,7 @@ Purchasely.Builder(applicationContext) } ``` -The init callback is now `{ error -> }` (single nullable `PLYError`); the v5 `{ isConfigured, error -> }` two-argument form was removed. +The init callback is now `{ error -> }` (single nullable `PLYError`); the v5 `{ isConfigured, error -> }` two-argument form was removed. `themeMode(...)` is settable on **both** the DSL and the fluent Builder since `6.0.1`, matching the iOS `themeMode(_:)` chain modifier. ## `apiKey` validation diff --git a/purchasely/references/android/migration-v6.md b/purchasely/references/android/migration-v6.md index 347b177..a4966fa 100644 --- a/purchasely/references/android/migration-v6.md +++ b/purchasely/references/android/migration-v6.md @@ -1,4 +1,4 @@ -# Android SDK v5.x -> v6.0.0-rc.1 Migration +# Android SDK v5.x -> v6.0.1 Migration This guide is Android-only. Do not apply it to iOS, React Native, Flutter, or Cordova until their v6 migrations are ready. @@ -6,24 +6,28 @@ To recognize legacy v5 code in a project before rewriting it, see [v5-api-refere ## Build And Dependency Changes -Pin every native Android Purchasely artifact to `6.0.0-rc.1`: +Pin every native Android Purchasely artifact to `6.0.1` (stable GA — chronology was `rc.1` → `rc.2` → `rc.3` → `6.0.1`; no `6.0.0` tag was ever cut): ```kotlin -implementation("io.purchasely:core:6.0.0-rc.1") -implementation("io.purchasely:google-play:6.0.0-rc.1") // Google Play -implementation("io.purchasely:player:6.0.0-rc.1") // optional video support +implementation("io.purchasely:core:6.0.1") +implementation("io.purchasely:google-play:6.0.1") // Google Play +implementation("io.purchasely:player:6.0.1") // optional video support // alternative stores (only if used): -implementation("io.purchasely:huawei-services:6.0.0-rc.1") // Huawei AppGallery -implementation("io.purchasely:amazon:6.0.0-rc.1") // Amazon Appstore +implementation("io.purchasely:huawei-services:6.0.1") // Huawei AppGallery +implementation("io.purchasely:amazon:6.0.1") // Amazon Appstore ``` -There is **no** `presentation-compose` artifact. For Compose embedding, wrap the Android `View` from `buildView(...)` in an `AndroidView`. +`io.purchasely:core` is a **fat AAR** — internal modules (`:common`, `:network`, `:storage`, …) are fused into it, so there are no separate Maven coordinates to add for them and their `io.purchasely.*` FQNs are unaffected. There is **no** `presentation-compose` artifact and no Compose composable (a Compose renderer is in active development for a future release). For Compose embedding today, wrap the Android `View` from `buildView(...)` in an `AndroidView`. -If `6.0.0-rc.1` is only installed on the developer machine, add `mavenLocal()` in `dependencyResolutionManagement.repositories` before `google()` and `mavenCentral()`. +`io.purchasely:core` bundles a `lint.jar` (an internal `:core-lint` module) — it runs automatically for any consumer and flags missing `context()` / `apiKey()` in the DSL/Builder, and `runningMode(PLYRunningMode.Full)` configured without `stores(...)`. -SDK v6 uses the modern Android toolchain: Gradle 9.3.0+, AGP 9.x, Kotlin 2.2.x (K2 compiler), JDK 17 to build, minSdk 23, compileSdk 36. +If `6.0.1` is only installed on the developer machine, add `mavenLocal()` in `dependencyResolutionManagement.repositories` before `google()` and `mavenCentral()`. -The reified entry points `interceptAction { … }` / `removeActionInterceptor()` are `inline` member functions of `Purchasely` targeting JVM 11 — no separate import beyond `io.purchasely.ext.Purchasely` is needed. Compile your Kotlin module with `jvmTarget = 11`, or use the `Class`-based overload. +SDK v6 uses the modern Android toolchain: Gradle **≥ 9.3** (floor; the SDK's own dev wrapper runs 9.6.1), AGP **9.0.1**, Kotlin **2.3.21** (K2 compiler — fixes issues present in the 2.2.x line used by early v6 release candidates), JDK 17 to build, `minSdk 23`, `compileSdk 36`, `targetSdk 35`. + +The reified entry points `interceptAction { … }` / `removeActionInterceptor()` are `inline` **member functions of `Purchasely`** (since `6.0.0-rc.3`) targeting JVM 11 — no separate import beyond `io.purchasely.ext.Purchasely` is needed. Compile your Kotlin module with `jvmTarget = 11`, or use the `Class`-based overload. + +> If you integrated against `6.0.0-rc.1` or `6.0.0-rc.2`, `interceptAction` / `removeActionInterceptor()` were **top-level extension functions** requiring `import io.purchasely.ext.interceptAction`. Remove that now-dead import when you upgrade to `6.0.1` — the member-function form resolves without it. With AGP 9, remove the explicit `org.jetbrains.kotlin.android` plugin; AGP provides Android Kotlin support directly. Keep specialized Kotlin plugins such as Compose or Serialization when the app uses them. Also remove `android { kotlinOptions { ... } }` once `kotlin-android` is gone. @@ -56,6 +60,7 @@ Purchasely { apiKey(apiKey) stores(listOf(GoogleStore())) runningMode(PLYRunningMode.Full) // default is Observer + themeMode(...) // optional, since 6.0.1 — system/light/dark (parity with iOS) logLevel(LogLevel.DEBUG) logcatEnabled(true) // optional, independent of logLevel allowDeeplink(true) @@ -75,6 +80,7 @@ Purchasely.Builder(applicationContext) .apiKey(apiKey) .stores(listOf(GoogleStore())) .runningMode(PLYRunningMode.Full) + .themeMode(...) // optional, since 6.0.1 — system/light/dark (parity with iOS) .logLevel(LogLevel.DEBUG) .logcatEnabled(true) .allowDeeplink(true) @@ -391,7 +397,7 @@ Purchasely.allowCampaigns = true // queued campaigns display immediately ## synchronize() now accepts callbacks (Observer mode) -`Purchasely.synchronize()` — called after a purchase completes in your own billing flow — gains optional callbacks and refreshes the subscriptions cache before firing `onSuccess`. Both default to `null`, so existing fire-and-forget calls keep working. +`Purchasely.synchronize()` gains optional callbacks and refreshes the subscriptions cache before firing `onSuccess`. Both default to `null`, so existing fire-and-forget calls keep working. ```kotlin // Fire-and-forget (still valid) @@ -404,6 +410,8 @@ Purchasely.synchronize( ) ``` +> Resolving a `.purchase` / `.restore` interceptor with `PLYInterceptResult.SUCCESS` **auto-synchronizes** the receipt — do not call `Purchasely.synchronize()` from inside the interceptor. Reserve manual `synchronize()` calls for transactions processed **outside** the interceptor flow (a "Restore Purchases" button, a client-side presentation with its own purchase button). + ## User Attributes User attribute mutation methods now return `Deferred`: @@ -433,6 +441,8 @@ Remove or replace: `Purchasely.subscriptionsFragment()`, all `PLYSubscriptions*` / `PLYSubscriptionDetail*` / `PLYSubscriptionCancellation*` fragments/views, the deeplinks `ply/subscriptions` and `ply/cancellation_survey[/PRODUCT_VENDOR_ID]`, and their `PLYEvent` subclasses (`SubscriptionListViewed`, `SubscriptionDetailsViewed`, `SubscriptionPlanTapped`, `SubscriptionCancelTapped`, `CancellationReasonPublished`) were removed. Build your own UI from `Purchasely.userSubscriptions { … }` / `Purchasely.userSubscriptionsHistory { … }`. +`Purchasely.displaySubscriptionCancellationInstruction()` is **also removed**, dropped between `6.0.0-rc.2` and `6.0.0-rc.3`. It is not called out in the upstream `MIGRATION_V6.md` — this plugin is currently the only documentation flagging the removal. There is no `presentSubscriptions()` on Android (that name belongs to React Native / Cordova, not native Android). + ### Plan offers — `intro*` / `INTRO_*` / `TRIAL_*` removed All `intro*` / `introductory*` methods and `INTRO_*` / `TRIAL_*` tags were removed in favor of unified `offer*` / `OFFER_*` equivalents (direct renames, identical behavior): @@ -448,8 +458,8 @@ All `intro*` / `introductory*` methods and `INTRO_*` / `TRIAL_*` tags were remov Mechanical, in order: -1. **Dependencies** pinned to `6.0.0-rc.1`; no `presentation-compose` artifact; alt-store artifacts use `huawei-services` / `amazon`. -2. **Toolchain**: Gradle 9.3.0+, AGP 9.x, Kotlin 2.2.x, JDK 17 to build, `minSdk 23`, `compileSdk 36`; `org.jetbrains.kotlin.android` plugin and `kotlinOptions {}` removed under AGP 9; Kotlin module on `jvmTarget = 11` (or interceptors use the `Class`-based overload). +1. **Dependencies** pinned to `6.0.1`; no `presentation-compose` artifact; alt-store artifacts use `huawei-services` / `amazon`. +2. **Toolchain**: Gradle ≥ 9.3, AGP 9.0.1, Kotlin 2.3.21, JDK 17 to build, `minSdk 23`, `compileSdk 36`, `targetSdk 35`; `org.jetbrains.kotlin.android` plugin and `kotlinOptions {}` removed under AGP 9; Kotlin module on `jvmTarget = 11` (or interceptors use the `Class`-based overload). 3. **Init**: `runningMode(PLYRunningMode.Full)` set if the app needs purchase validation / auto-close; `PaywallObserver` -> `Observer`; init callback is `start { error -> }`. 4. **Imports** moved to `io.purchasely.ext.presentation.*`. 5. **Builder** has no `flowId(...)` / `productId(...)` / `planId(...)`; Flows shown via `app_scheme://ply/flows/FLOW_ID`. diff --git a/purchasely/references/concepts/README.md b/purchasely/references/concepts/README.md index 903c6d1..7698441 100644 --- a/purchasely/references/concepts/README.md +++ b/purchasely/references/concepts/README.md @@ -25,9 +25,11 @@ When a topic also has a deeper platform-specific take (e.g. SwiftUI lifecycle, J | [subscription-management.md](subscription-management.md) | Opening the native Manage Subscription page (App Store / Play Store) | | [promotional-offers.md](promotional-offers.md) | Offer types, Apple promo offers, Google developer-determined offers, offer codes, win-back | | [dynamic-offerings.md](dynamic-offerings.md) | `setDynamicOffering` runtime plan/offer overrides — applied server-side at fetch, register-before-fetch rule, same-plan billing-type pitfall | -| [monthly-commitment.md](monthly-commitment.md) | Apple advance-commitment (12-month billed monthly) `PLYBillingPlanType` — iOS 26.4+, eligibility (excl. US/SG), setup | +| [monthly-commitment.md](monthly-commitment.md) | Apple "Monthly with 12-Month Commitment" `PLYBillingPlanType` (iOS 26.4+, eligibility excl. US/SG) and Google Play native installment subscriptions — cross-platform pricing tags (`{{MONTHLY_AMOUNT}}`, `{{PRICE}}`, `{{AMOUNT}}`), `INSTALLMENT_*` webhooks | +| [web-checkout.md](web-checkout.md) | Stripe Payment Links via the `webCheckout` action, audience targeting, `WEB_CHECKOUT_*` events | | [campaigns.md](campaigns.md) | No-code Console automations (trigger / placement-based), `allowCampaigns` + `allowDeeplink` (native iOS/Android, React Native, Flutter v6, and Cordova v6), SDK ≥ 5.1.0 | | [analytics-integration.md](analytics-integration.md) | Forwarding UI events to Firebase / Amplitude / AppsFlyer + analytics wrapper pattern | +| [rendering-engine.md](rendering-engine.md) | How a Screen renders: iOS UIKit component tree + tolerant decoding, Android Views/fat-AAR, image cache, Lottie bridge, known rendering bugs | ## When to load @@ -40,11 +42,13 @@ When a topic also has a deeper platform-specific take (e.g. SwiftUI lifecycle, J | Adding subscription gating | `subscription-checks.md`, `subscription-management.md` | | Adding retention / win-back paywalls | `promotional-offers.md`, `campaigns.md` | | Overriding a paywall's plan/offer at runtime (remote config, cohorts, experiments) | `dynamic-offerings.md` | -| Setting up 12-month commitment billed monthly (iOS) | `monthly-commitment.md`, `dynamic-offerings.md` | +| Setting up 12-month commitment billed monthly (Apple) or installment subscriptions (Google Play) | `monthly-commitment.md`, `dynamic-offerings.md` | | Adding scheduled or event-driven paywalls | `campaigns.md` | +| Adding a Stripe / web checkout button | `web-checkout.md` | | Wiring analytics / tracking | `analytics-integration.md`, `user-identity.md` | | Improving paywall perceived performance | `presentation-cache.md` (preload pattern) | | Debugging stuck paywalls / blank presentations | `presentation-types.md`, `presentation-cache.md`, `paywall-actions.md` | +| Debugging rendering issues (missing components, blank images, silent Lottie) | `rendering-engine.md` | | Embedding a native login / custom form / legacy paywall inside a Flow | `byos.md` (iOS + Android, SDK ≥ 5.6.0) | | Adding or debugging Lottie animations in Purchasely Screens | `lottie-animations.md` (iOS / Android native bridge; cross-platform apps configure host projects) | | Configuring multi-step buttons (purchase + next step, purchase + placement) | `paywall-actions.md` § Chaining multiple actions | diff --git a/purchasely/references/concepts/campaigns.md b/purchasely/references/concepts/campaigns.md index 7ef6328..18318e6 100644 --- a/purchasely/references/concepts/campaigns.md +++ b/purchasely/references/concepts/campaigns.md @@ -41,6 +41,8 @@ Docs: Trigger-based campaigns can be deferred until your app explicitly authorises display — useful when you have a splash / onboarding / login flow that must finish first. On **native iOS/Android v6, Flutter v6, and Cordova v6** campaigns and deeplinks are governed by **two independent flags**: `allowCampaigns` (campaign display) and `allowDeeplink` (deeplink presentations) — both default to `true`. **React Native v6** controls deeplink/campaign presentation readiness with `.allowDeeplink(...)` on the builder (it replaces v5's `readyToOpenDeeplink`); it also exposes an `.allowCampaigns(...)` modifier. +> **Migration note — `allowCampaigns` default flipped from `false` (v5) to `true` (v6) on iOS/Android/Flutter.** A client upgrading from v5 who never set `allowCampaigns` explicitly relied on the v5 default of `false` (campaigns effectively off until the app opted in). After upgrading to v6 the same code now runs with `allowCampaigns` defaulting to `true` — campaigns configured on the dashboard can start appearing even though nothing changed in the app's code. **This is expected v6 behaviour, not a regression** — if you need the old opt-in behaviour, set `allowCampaigns(false)` explicitly at init and flip it once your app is ready. Note also that actually **opening** a campaign's deeplink is additionally gated on the SDK's config being ready (fetched and applied) — `allowCampaigns` / `allowDeeplink` control *authorisation*, config-readiness controls *capability*. + ### iOS (Swift) ```swift @@ -64,8 +66,11 @@ Or at init via the DSL / Builder: `allowCampaigns(false)`. Defaults to `true`. ### React Native (v6) ```ts -// Set at init on the builder; defaults to false. Flip to true once your -// splash / onboarding / login flow is complete so queued presentations can show. +// Set at init on the builder. The native default is true; if you omit +// .allowDeeplink(...) the RN bridge doesn't send the key and the native +// default (true) applies. Set it to false explicitly to queue campaigns/ +// deeplinks during a splash / onboarding / login flow, then flip to true +// once that flow is complete. await Purchasely.builder('YOUR_API_KEY') .allowDeeplink(true) // replaces v5's readyToOpenDeeplink(true) .start(); @@ -96,7 +101,7 @@ await PurchaselyBuilder.apiKey('') `allowCampaigns` is set at init via `PurchaselyBuilder` and is a separate flag from `allowDeeplink`. Both default to `true`. > **v6 native / Flutter / Cordova:** `allowCampaigns` and `allowDeeplink` are **independent** flags (in v5 a single flag governed both). Control campaign display with `allowCampaigns`; control deeplink presentations with `allowDeeplink` (defaults to `true`). Android also **auto-intercepts** deeplinks, so no manual `handleDeeplink` call is required for them. -> **React Native v6:** `.allowDeeplink(...)` on the builder gates campaign/deeplink presentations (defaults to `false`); there is also an `.allowCampaigns(...)` modifier. Deeplinks you receive yourself are passed with `Purchasely.handleDeeplink(url)` — the v5 `isDeeplinkHandled` was removed and renamed to `handleDeeplink` (no alias). +> **React Native v6:** `.allowDeeplink(...)` on the builder gates campaign/deeplink presentations. The native default is `true` on every platform, RN included — if the app never calls `.allowDeeplink(...)`, the RN bridge simply omits the key and the native default (`true`) applies; there is also an `.allowCampaigns(...)` modifier. Deeplinks you receive yourself are passed with `Purchasely.handleDeeplink(url)` — the v5 `isDeeplinkHandled` was removed and renamed to `handleDeeplink` (no alias). > If you implement a [UI Handler](https://docs.purchasely.com/docs/ui-handler-deeplinks) to manage deeplink display yourself, **keep the presentation object returned** and do not refetch it — refetching loses the campaign context. > **React Native v6 — dismiss handler for SDK-opened presentations.** Campaigns, deeplinks and Promoted IAP open presentations the app didn't trigger. Register a single global handler to observe their outcome (the v6 replacement for v5's `setDefaultPresentationResultCallback`): @@ -112,7 +117,7 @@ await PurchaselyBuilder.apiKey('') ## Placement-based campaigns — no extra SDK code -You already fetch the placement (native iOS/Android v6: `PLYPresentationBuilder.forPlacementId("PLACEMENT_ID")` / `PLYPresentation { placementId("PLACEMENT_ID") }`; React Native v6: `Purchasely.presentation.placement("PLACEMENT_ID").build()`; Flutter v6: `PresentationBuilder.placement("PLACEMENT_ID").build()`; Cordova v6: `fetchPresentationForPlacement("PLACEMENT_ID")`). When a campaign targets that placement and the user matches the audience, the SDK substitutes the campaign's Screen for the Placement's default rules. Same presentation-type handling, same display path. Nothing to change in your code. +You already fetch the placement (native iOS/Android v6: `PLYPresentationBuilder.forPlacementId("PLACEMENT_ID")` / `PLYPresentation { placementId("PLACEMENT_ID") }`; React Native v6: `Purchasely.presentation.placement("PLACEMENT_ID").build()`; Flutter v6: `PLYPresentationBuilder.placement("PLACEMENT_ID").build()`; Cordova v6: `fetchPresentationForPlacement("PLACEMENT_ID")`). When a campaign targets that placement and the user matches the audience, the SDK substitutes the campaign's Screen for the Placement's default rules. Same presentation-type handling, same display path. Nothing to change in your code. ## Typical use cases @@ -139,8 +144,8 @@ Property bag includes `campaign_id`, `campaign_name`, `screen_id`, `audience_id` ## Anti-patterns -- ❌ **Leaving campaigns gated.** If you set `allowCampaigns = false` (native iOS/Android v6, Flutter v6, Cordova v6, and React Native v6 when using `.allowCampaigns(...)`) / start with `.allowDeeplink(false)` (React Native v6) and never re-authorise display, trigger-based campaigns silently never appear. (On React Native v6 `.allowDeeplink(...)` is set once on the builder before `start()`; start with `true` once your app is ready to show SDK-opened presentations.) -- ❌ **Re-enabling campaigns too early.** If your splash screen runs after `start()`, flipping `allowCampaigns = true` (native iOS/Android v6, Flutter v6, Cordova v6, and React Native v6 when using `.allowCampaigns(...)`) / `.allowDeeplink(true)` (React Native v6) while it is still up lands the campaign paywall on top of the splash. Wait until your launch routine is complete. (On React Native v6, since `.allowDeeplink(...)` is decided at init, defer `start()` itself until after the splash, or keep deeplink display off until the launch routine completes.) +- ❌ **Leaving campaigns gated.** If you explicitly set `allowCampaigns(false)` / `allowDeeplink(false)` (any v6 platform — native iOS/Android, Flutter, Cordova, React Native) and never re-authorise display, trigger-based campaigns silently never appear. Flip both back to `true` once your app is ready to show SDK-opened presentations. +- ❌ **Re-enabling campaigns too early.** If your splash screen runs after `start()`, flipping `allowCampaigns(true)` / `allowDeeplink(true)` while the splash screen is still up lands the campaign paywall on top of it. Wait until your launch routine is complete before re-authorising display. - ❌ **Coupling capping logic to placement-based campaigns.** Capping only applies on triggers — if you need capping on a placement, build the cap into your audience attribute or use a trigger. - ❌ **Refetching the presentation returned by the deeplink handler.** You lose the campaign context (audience match, screen variant, exposure tracking). - ❌ **Targeting subscribers with promotional offers without eligibility audience.** See [promotional-offers.md](promotional-offers.md#eligibility-is-your-responsibility-promotional-offers--developer-determined-offers). diff --git a/purchasely/references/concepts/monthly-commitment.md b/purchasely/references/concepts/monthly-commitment.md index 5da8600..01ab450 100644 --- a/purchasely/references/concepts/monthly-commitment.md +++ b/purchasely/references/concepts/monthly-commitment.md @@ -1,10 +1,15 @@ -# Monthly Commitment Billing (Apple Advance Commitment) — iOS +# Monthly Commitment Billing — Apple Advance Commitment & Google Play Installments -Applies to: **iOS only** (native iOS SDK). Requires **iOS 26.4+** and **SDK v6+**. +Applies to: **iOS, Android, React Native, Flutter, Cordova**. Two independent store mechanisms fall under this concept: -Apple's **advance commitment** subscriptions let a long commitment period (e.g. a **12-month** commitment) be **billed monthly** instead of charged once up front. StoreKit models this as a *billing plan type* on the subscription: **up-front** vs **monthly**. The user commits to 12 months but is charged month by month. +- Apple's **advance commitment** — a **12-month** commitment **billed monthly** instead of charged once up front. iOS-only purchase mechanism (StoreKit), surfaced via `PLYBillingPlanType`. Requires **iOS 26.4+** and **Purchasely SDK 6.0+** — native iOS, and the iOS side of Flutter, React Native, and Cordova bridges. +- Google Play's **native installment subscriptions** — Android's own commitment-plan primitive, configured entirely in the **Play Console** with no SDK-side plan type. -Purchasely surfaces this as the public enum **`PLYBillingPlanType`**: +Official doc: [Understanding Offer Types — 12-Month Commitment](https://docs.purchasely.com/docs/understanding-offer-types). + +## Apple — advance commitment (`PLYBillingPlanType`) + +StoreKit models the commitment as a *billing plan type* on the subscription: **up-front** vs **monthly**. The user commits to 12 months but is charged month by month. Purchasely surfaces this as the public enum **`PLYBillingPlanType`**: | Case | Meaning | |------|---------| @@ -14,27 +19,21 @@ Purchasely surfaces this as the public enum **`PLYBillingPlanType`**: `.unspecified` is a deliberate third state — it is **not** the same as `.upFront`. Only an explicitly configured up-front commitment resolves to `.upFront`. -## Eligibility — all of these must hold +### Eligibility — all of these must hold - **iOS 26.4 or later** on the device. The StoreKit billing-plan-type API and the monthly-commitment purchase option exist only from 26.4; on older iOS the feature is unavailable. - **Purchasely iOS SDK v6+**. -- **Storefront**: monthly commitment is offered in most App Store countries but **not** in the **United States** and **Singapore** (as of Apple's current rollout). On a US or Singapore storefront the SDK automatically **falls back from `.monthly` to `.upFront`** — a US/SG tester seeing `.upFront` is expected behavior, not a bug. +- **Storefront**: monthly commitment is offered in most App Store countries but **not** in the **United States** and **Singapore** (as of Apple's current rollout). Outside eligible storefronts, or on an older OS that doesn't support the plan, the **App Store itself falls back** to the plan's configured **"1 Year Upfront"** price — no app-side branching required, the store resolves this transparently. The SDK's `billingPlanType` simply reflects the resolved type, so a US/SG tester seeing `.upFront` is expected behavior, not a bug. - **App Store Connect**: the product must be configured with advance-commitment (monthly) pricing. - **Purchasely configuration**: the plan must carry the monthly billing plan type — set on the plan in the Screen Composer, or supplied at runtime via a [dynamic offering](dynamic-offerings.md) (`billingPlanType: .monthly`, iOS-only). -## Setup checklist +### Setup checklist 1. **App Store Connect** — enable advance commitment / monthly billing on the (yearly) product. 2. **Purchasely Console** — set the plan's commitment billing type to monthly on the paywall, or pass it via `setDynamicOffering(..., billingPlanType: .monthly)`. 3. **App** — Purchasely iOS SDK v6+, test on a real device or simulator running **iOS 26.4+**, signed into a **non-US / non-Singapore** App Store account (sandbox or TestFlight). -## How it surfaces at runtime - -- In the **purchase action interceptor**, `parameters.billingPlanType` reflects the resolved type for the tapped plan. -- In **Full** running mode, the SDK passes the correct StoreKit purchase option automatically when the resolved type is `.monthly`. -- In **Observer** mode, your own purchase code reads `parameters.billingPlanType` to decide how to purchase — so an incorrect value here changes what your app buys. - -## When you expect `.monthly` but get `.unspecified` +### When you expect `.monthly` but get `.unspecified` Check, in order: @@ -43,8 +42,42 @@ Check, in order: 3. You did **not** map the **same plan to multiple offering references with different billing types** in one presentation — that ambiguity resolves to `.unspecified`. See the pitfall in [Dynamic offerings](dynamic-offerings.md#pitfall-one-plan--one-billing-type-per-presentation). 4. The plan actually carries the monthly commitment type in the Console / dynamic offering. +## Google Play — native installment subscriptions + +Google Play has its own native **installment subscriptions** mechanism, independent of Apple's plan type. It requires **no SDK-side configuration** — the installment terms (commitment length, monthly price) are configured entirely in the **Google Play Console**, on the base plan. There is no Android equivalent of `PLYBillingPlanType` to set: once the base plan is configured as an installment plan in the Play Console, Purchasely surfaces the resulting commitment info the same way it does for Apple (see below). + +## Screen Composer pricing tags + +Use these tags in Screen Composer text blocks to render the right price for a commitment plan, on either store: + +| Tag | Renders | +|-----|---------| +| `{{MONTHLY_AMOUNT}}` | The per-month charge | +| `{{PRICE}}` | The plan's display price | +| `{{AMOUNT}}` | The total commitment amount | + +## How it surfaces at runtime + +- In the **purchase action interceptor**, `parameters.billingPlanType` reflects the resolved billing plan for the tapped plan (native iOS/Android; `params.billingPlanType` in the React Native / Flutter / Cordova bridges). A companion `commitmentInfo` field — `plan.commitmentInfo`, typed `PLYCommitmentInfo` / `PLYCommitmentProgress` on iOS — carries the commitment length and progress for the selected plan. +- In **Full** running mode, the SDK passes the correct StoreKit purchase option automatically when the resolved type is `.monthly`. +- In **Observer** mode, your own purchase code reads `parameters.billingPlanType` to decide how to purchase — so an incorrect value here changes what your app buys. + +See [paywall-actions.md](paywall-actions.md) for the full per-action interceptor contract these fields ride on. + +## Webhooks — server-to-server + +Installment billing events forward through the usual S2S webhook channel: + +- `INSTALLMENT_PAID` +- `INSTALLMENT_REFUNDED` + +Both carry `commitment_*` attributes (commitment length, progress, remaining installments) alongside the standard subscription payload. + ## Related -- [Dynamic offerings](dynamic-offerings.md) — how the `billingPlanType` is supplied at runtime, and the same-plan pitfall -- [Common issues](../troubleshooting/common-issues.md) — diagnostic entry for wrong / missing billing type +- [Dynamic offerings](dynamic-offerings.md) — how `billingPlanType` is supplied at runtime (iOS-only argument), and the same-plan pitfall +- [Promotional offers](promotional-offers.md) — the other Apple/Google offer mechanisms (intro offers, promotional offers, offer codes) +- [Paywall actions](paywall-actions.md) — full `purchase` action interceptor payload - [Programmatic purchases](programmatic-purchases.md) — app-side purchase APIs (Observer mode) +- [Subscription management](subscription-management.md) — opening the native Manage Subscription page for a committed plan +- [Common issues](../troubleshooting/common-issues.md) — diagnostic entry for wrong / missing billing type diff --git a/purchasely/references/concepts/observer-mode-post-purchase.md b/purchasely/references/concepts/observer-mode-post-purchase.md index f88fdd8..689152d 100644 --- a/purchasely/references/concepts/observer-mode-post-purchase.md +++ b/purchasely/references/concepts/observer-mode-post-purchase.md @@ -6,28 +6,30 @@ When the SDK runs in [Observer mode](running-modes.md), your app owns the billin The ordering and the exact API names matter. Get them wrong and you'll see frozen paywalls, double purchase attempts, or stale audience targeting on follow-up screens. -> **Full vs Observer — who closes the paywall.** The SDK appends an implicit `close_all` after a lone `purchase` / `restore` **only in Full mode** (verified in the SDK source: Android `Components.kt` gates it on `Purchasely.runningMode == PLYRunningMode.Full`; iOS `DefaultActionExecutor.appendCloseIfNeeded` early-returns unless `runningMode.validatesTransactions`). **In Observer mode the SDK does NOT auto-close after a purchase/restore.** The post-purchase interceptor flow is an Observer-mode flow (your app runs its own billing), so after you resolve the interceptor with a successful result you **must dismiss the paywall yourself** — unless you wire a `close` / `close_all` action on the button in the Console. Native iOS/Android call `Purchasely.closeAllScreens()` after the interceptor has resolved (from your async billing-result handler), not inside the interceptor closure before returning the result — that races the SDK. React Native v6 calls `request.close()` on the `PLYPresentationRequest`, Flutter v6 calls `presentation.close()` on the loaded `Presentation`, and Cordova v6 calls `Purchasely.closePresentation()`. +> **Full vs Observer — who closes the paywall.** The SDK appends an implicit `close_all` after a lone `purchase` / `restore` **only in Full mode** (verified in the SDK source: Android `Components.kt` gates it on `Purchasely.runningMode == PLYRunningMode.Full`; iOS `DefaultActionExecutor.appendCloseIfNeeded` early-returns unless `runningMode.validatesTransactions`). **In Observer mode the SDK does NOT auto-close after a purchase/restore.** The post-purchase interceptor flow is an Observer-mode flow (your app runs its own billing), so after you resolve the interceptor with a successful result you **must dismiss the paywall yourself** — unless you wire a `close` / `close_all` action on the button in the Console. Native iOS/Android call `Purchasely.closeAllScreens()` after the interceptor has resolved (from your async billing-result handler), not inside the interceptor closure before returning the result — that races the SDK. React Native v6 calls `request.close()` on the `PLYPresentationRequest`, Flutter v6 calls `presentation.close()` on the loaded `PLYPresentation`, and Cordova v6 calls `Purchasely.closePresentation()`. + +> **`synchronize()` in the interceptor is obsolete — do not call it there.** In v6, returning a success result from the `purchase` / `restore` action interceptor in Observer mode already tells the SDK the transaction succeeded, and the SDK **auto-synchronizes** with Purchasely's servers as part of resolving that action. Calling `Purchasely.synchronize()` yourself inside the interceptor is redundant and is **not** the recommended flow below. Manual `synchronize()` calls are for purchases that happen **outside** the interceptor entirely — a fully custom home-grown sale screen, or a [BYOS](byos.md) screen driving its own store calls — where there is no interceptor resolution to trigger the auto-sync. ## The recommended sequence After a successful Observer-mode purchase: -1. **`Purchasely.synchronize()`** — tells Purchasely to re-pull receipt state from the store servers. In v6 native SDKs and Cordova accept optional callbacks (iOS `success:/failure:`, Android `onSuccess = { plan -> } / onError = { error -> }`, Cordova `success, error`); the cache is refreshed before success fires. React Native v6 returns `Promise` — `await Purchasely.synchronize()` resolves once the native bridge confirms. -2. **Resolve the interceptor** — tell the SDK's action interceptor that **you** handled the purchase. Native iOS/Android v6: return `PLYInterceptResult.success` / `PLYInterceptResult.SUCCESS`. React Native v6: return `'success'`. Flutter v6: return `InterceptResult.success`. Cordova v6: return or resolve `Purchasely.InterceptResult.success`. This means "do not run the SDK's own purchase flow on top of mine." In Observer mode the SDK does **not** auto-close on a successful result — you dismiss in step 3. -3. **Dismiss the paywall** — Observer mode does not auto-close after a purchase/restore (that implicit `close_all` is Full-only). After resolving the interceptor, dismiss the paywall yourself: native iOS/Android v6 call `Purchasely.closeAllScreens()`; React Native v6 calls `request.close()` on the `PLYPresentationRequest`; Flutter v6 calls `presentation.close()` on the loaded `Presentation`; Cordova v6 calls `Purchasely.closePresentation()`. You can skip this step if a `close` / `close_all` action is wired on the button in the Console — then the SDK closes on that action. +1. **Run your billing flow** — your own StoreKit / Play Billing call, or whatever custom billing stack the app already has. +2. **Resolve the interceptor** — tell the SDK's action interceptor that **you** handled the purchase. Native iOS/Android v6: return `PLYInterceptResult.success` / `PLYInterceptResult.SUCCESS`. React Native v6: return `'success'`. Flutter v6: return `PLYInterceptResult.success`. Cordova v6: return or resolve `Purchasely.InterceptResult.success`. This means "do not run the SDK's own purchase flow on top of mine," and — new in v6 — it **auto-synchronizes** with Purchasely's servers; do not call `Purchasely.synchronize()` yourself here. In Observer mode the SDK does **not** auto-close on a successful result — you dismiss in step 3. +3. **Dismiss the paywall** — Observer mode does not auto-close after a purchase/restore (that implicit `close_all` is Full-only). After resolving the interceptor, dismiss the paywall yourself: native iOS/Android v6 call `Purchasely.closeAllScreens()`; React Native v6 calls `request.close()` on the `PLYPresentationRequest`; Flutter v6 calls `presentation.close()` on the loaded `PLYPresentation`; Cordova v6 calls `Purchasely.closePresentation()`. You can skip this step if a `close` / `close_all` action is wired on the button in the Console — then the SDK closes on that action. -> **Resolve first, then dismiss — never inside the closure.** Do not call `closeAllScreens()` / `request.close()` / `presentation.close()` / `closePresentation()` inside the interceptor closure *before* returning the result — that races the SDK. Dismiss **after** the interceptor handler has resolved its result (`PLYInterceptResult`, string result, or `InterceptResult`), i.e. from your async billing-result handler (e.g. `onBillingSuccess()`), which runs once the suspended interceptor has resolved. +> **Resolve first, then dismiss — never inside the closure.** Do not call `closeAllScreens()` / `request.close()` / `presentation.close()` / `closePresentation()` inside the interceptor closure *before* returning the result — that races the SDK. Dismiss **after** the interceptor handler has resolved its result (`PLYInterceptResult` on native iOS/Android and Flutter v6, a string result on React Native v6, or `Purchasely.InterceptResult` on Cordova v6), i.e. from your async billing-result handler (e.g. `onBillingSuccess()`), which runs once the suspended interceptor has resolved. ## Dismissal API per platform -On **native v6 in Observer mode** the SDK does **not** dismiss after a successful purchase/restore (the implicit `close_all` is Full-only), so you call `Purchasely.closeAllScreens()` yourself after resolving the interceptor — unless a `close` / `close_all` action is configured on the button in the Console. `closeAllScreens()` is the native v6 dismissal method (it replaces v5's `closeDisplayedPresentation()` and tears down multi-step Flow paywalls correctly). React Native v6 has no `closeAllScreens()` — dismiss with `request.close()` on the `PLYPresentationRequest` you built. Flutter v6 dismisses with `presentation.close()` on the loaded `Presentation`. Cordova v6 exposes `closePresentation()` on the public JS bridge; do not generate bridge code that calls native `closeAllScreens()` unless the project has added its own native bridge. +On **native v6 in Observer mode** the SDK does **not** dismiss after a successful purchase/restore (the implicit `close_all` is Full-only), so you call `Purchasely.closeAllScreens()` yourself after resolving the interceptor — unless a `close` / `close_all` action is configured on the button in the Console. `closeAllScreens()` is the native v6 dismissal method (it replaces v5's `closeDisplayedPresentation()` and tears down multi-step Flow paywalls correctly). React Native v6 has no `closeAllScreens()` — dismiss with `request.close()` on the `PLYPresentationRequest` you built. Flutter v6 dismisses with `presentation.close()` on the loaded `PLYPresentation`. Cordova v6 exposes `closePresentation()` on the public JS bridge; do not generate bridge code that calls native `closeAllScreens()` unless the project has added its own native bridge. | Platform | Post-purchase dismissal (Observer mode) | |----------|-------------------------| | iOS | Resolve with `.success`, then call `Purchasely.closeAllScreens()` (from your billing-result handler, after the interceptor resolves) — or wire a `close` action in the Console. It is `@MainActor`-isolated; from a non-isolated context wrap in `Task { @MainActor in Purchasely.closeAllScreens() }`. | | Android | Resolve with `PLYInterceptResult.SUCCESS`, then call `Purchasely.closeAllScreens()` (from your billing-result handler, after the interceptor resolves) — or wire a `close` action in the Console. No threading constraint. | | React Native | Resolve with `'success'`, then call `request.close()` on the `PLYPresentationRequest` — or wire a `close` action in the Console. | -| Flutter | Resolve with `InterceptResult.success`, then call `presentation.close()` on the loaded `Presentation` — or wire a `close` action in the Console. | +| Flutter | Resolve with `PLYInterceptResult.success`, then call `presentation.close()` on the loaded `PLYPresentation` — or wire a `close` action in the Console. | | Cordova | Resolve with `Purchasely.InterceptResult.success`, then call `Purchasely.closePresentation()` in the public JS bridge. | > **Full mode** dismisses automatically: the SDK appends `close_all` after a lone purchase/restore, so no manual `closeAllScreens()` is needed there. @@ -36,15 +38,15 @@ On **native v6 in Observer mode** the SDK does **not** dismiss after a successfu ### iOS (Swift) — async interceptor returns the result directly -In v6 the `.purchase` interceptor is an async closure that **returns** a `PLYInterceptResult`. Run your billing flow, `synchronize`, then return `.success`. In Observer mode the SDK does not auto-close, so dismiss the paywall with `Purchasely.closeAllScreens()` **after** the interceptor has returned — or wire a `close` action in the Console. +In v6 the `.purchase` interceptor is an async closure that **returns** a `PLYInterceptResult`. Run your billing flow, then return `.success`. In Observer mode the SDK does not auto-close, so dismiss the paywall with `Purchasely.closeAllScreens()` **after** the interceptor has returned — or wire a `close` action in the Console. ```swift Purchasely.interceptAction(.purchase) { info, params in let purchased = await MyBilling.purchase(params?.plan) guard purchased else { return .failed } - try? await synchronizeReceipt() // await only if a follow-up placement targets subscribers - return .success // app handled it; do NOT close here — that races the SDK + return .success // app handled it; returning success auto-synchronizes with Purchasely — + // do NOT call synchronize() here, and do NOT close here (that races the SDK) } // Called after the interceptor has resolved (Observer mode does not auto-close). @@ -53,51 +55,38 @@ Purchasely.interceptAction(.purchase) { info, params in private func onBillingSuccess() { Purchasely.closeAllScreens() // dismiss the paywall ourselves in Observer mode } - -private func synchronizeReceipt() async throws { - try await withCheckedThrowingContinuation { cont in - Purchasely.synchronize( - success: { cont.resume() }, - failure: { cont.resume(throwing: $0 ?? NSError(domain: "Purchasely", code: -1)) } - ) - } -} ``` ### Android (Kotlin) — suspend interceptor bridges your billing flow -In v6 the interceptor is a suspend closure that **returns** a `PLYInterceptResult`. Bridge your callback-based billing client with `suspendCancellableCoroutine`, then call `synchronize(...)` and return `SUCCESS`. In Observer mode the SDK does not auto-close, so dismiss the paywall with `Purchasely.closeAllScreens()` from your billing-result handler **after** the interceptor has resolved — or wire a `close` action in the Console. +In v6 the interceptor is a suspend closure that **returns** a `PLYInterceptResult`. Bridge your callback-based billing client with `suspendCancellableCoroutine`, then return `SUCCESS` — returning success auto-synchronizes with Purchasely, no manual `synchronize(...)` call needed. In Observer mode the SDK does not auto-close, so dismiss the paywall with `Purchasely.closeAllScreens()` from your billing-result handler **after** the interceptor has resolved — or wire a `close` action in the Console. ```kotlin Purchasely.interceptAction { info, purchase -> - val result = suspendCancellableCoroutine { cont -> + suspendCancellableCoroutine { cont -> myBilling.purchase(purchase.plan) { billing -> when (billing) { - BillingResult.SUCCESS -> { - Purchasely.synchronize( - onSuccess = { plan -> - // refresh UI; plan is the validated PLYPlan or null. - // Observer mode does not auto-close — dismiss ourselves here, - // after the interceptor has resolved (skip if a `close` action - // is configured on the button in the Console). - Purchasely.closeAllScreens() - }, - onError = { error -> /* surface failure */ } - ) - cont.resume(PLYInterceptResult.SUCCESS) // resolve; do NOT close inside the closure - } + BillingResult.SUCCESS -> cont.resume(PLYInterceptResult.SUCCESS) + // resolve; auto-synchronizes — do NOT close inside the closure BillingResult.CANCELLED -> cont.resume(PLYInterceptResult.NOT_HANDLED) else -> cont.resume(PLYInterceptResult.FAILED) } } } - result +} + +// Called from your billing client's own success listener (not chained off the +// interceptor's return), after the interceptor has resolved (Observer mode +// does not auto-close). Skip this if a `close` action is configured on the +// button in the Console. +private fun onBillingSuccess() { + Purchasely.closeAllScreens() // dismiss the paywall ourselves in Observer mode } ``` ### React Native (TypeScript) — async interceptor returns the result directly -In v6 the `purchase` interceptor is an async handler that **returns** a string result. Run your billing flow, `await synchronize()`, then return `'success'`. In Observer mode the SDK does not auto-close, so dismiss the paywall with `request.close()` on the `PLYPresentationRequest` you built **after** the interceptor has returned — or wire a `close` action in the Console. +In v6 the `purchase` interceptor is an async handler that **returns** a string result. Run your billing flow, then return `'success'`. In Observer mode the SDK does not auto-close, so dismiss the paywall with `request.close()` on the `PLYPresentationRequest` you built **after** the interceptor has returned — or wire a `close` action in the Console. ```ts // `request` is the PLYPresentationRequest you built and displayed: @@ -108,8 +97,8 @@ Purchasely.interceptAction('purchase', async (info, payload) => { const purchased = await myBilling.purchase(payload?.plan?.productId); if (!purchased) return 'failed'; - await Purchasely.synchronize(); // resolves once the native bridge confirms - return 'success'; // app handled it; do NOT close here — that races the SDK + return 'success'; // app handled it; returning success auto-synchronizes — do NOT call + // synchronize() here, and do NOT close here (that races the SDK) }); // Called after the interceptor has resolved (Observer mode does not auto-close). @@ -121,22 +110,23 @@ async function onPurchaseSuccess() { ### Flutter (Dart) — async interceptor returns the result directly -In v6 the `.purchase` interceptor is an async callback that **returns** an `InterceptResult`. Run your billing flow, `await synchronize()`, then return `InterceptResult.success`. In Observer mode the SDK does not auto-close, so dismiss the paywall with `presentation.close()` on the loaded `Presentation` **after** the interceptor has resolved — or wire a `close` action in the Console. +In v6 the `.purchase` interceptor is an async callback that **returns** a `PLYInterceptResult`. Run your billing flow, then return `PLYInterceptResult.success`. In Observer mode the SDK does not auto-close, so dismiss the paywall with `presentation.close()` on the loaded `PLYPresentation` **after** the interceptor has resolved — or wire a `close` action in the Console. ```dart -Purchasely.interceptAction(PresentationActionKind.purchase, (info, payload) async { - if (payload is! PurchasePayload) return InterceptResult.notHandled; +Purchasely.interceptAction(PLYPresentationActionKind.purchase, (info, payload) async { + if (payload is! PLYPurchasePayload) return PLYInterceptResult.notHandled; final purchased = await myBilling.purchase(payload.plan['productId']); - if (!purchased) return InterceptResult.failed; + if (!purchased) return PLYInterceptResult.failed; - await Purchasely.synchronize(); // resolves once the native bridge confirms - return InterceptResult.success; // app handled it; do NOT close here — that races the SDK + return PLYInterceptResult.success; // app handled it; returning success auto-synchronizes — + // do NOT call synchronize() here, and do NOT close here + // (that races the SDK) }); // Called after the interceptor has resolved (Observer mode does not auto-close). // Skip this if a `close` action is configured on the button in the Console. -Future onPurchaseSuccess(Presentation presentation) async { +Future onPurchaseSuccess(PLYPresentation presentation) async { await presentation.close(); // dismiss the paywall ourselves in Observer mode } ``` @@ -146,14 +136,9 @@ Future onPurchaseSuccess(Presentation presentation) async { ```js Purchasely.interceptAction(Purchasely.PresentationAction.purchase, function (info, parameters) { return myBilling.purchase(parameters.plan).then(function (ok) { - if (!ok) return Purchasely.InterceptResult.failed; - - return new Promise(function (resolve) { - Purchasely.synchronize( - function () { resolve(Purchasely.InterceptResult.success); }, - function () { resolve(Purchasely.InterceptResult.failed); }, - ); - }); + // returning success auto-synchronizes with Purchasely — do NOT call + // Purchasely.synchronize() here + return ok ? Purchasely.InterceptResult.success : Purchasely.InterceptResult.failed; }); }); @@ -164,17 +149,14 @@ function onPurchaseSuccess() { ## Optional: chaining a follow-up placement -Some apps display a follow-up paywall after a successful purchase — a thank-you screen, a premium feature tour, a one-tap upsell, etc. **This is not part of the SDK contract**: it's just another presentation fetch with whatever placement ID you've configured on the Console (e.g. `"post_purchase"`, `"thank_you"`, `"premium_welcome"` — name it whatever you want, just match it in the dashboard). Native iOS/Android v6 build it with `PLYPresentationBuilder` / the `PLYPresentation { }` DSL; React Native v6 and Flutter v6 build it with `Purchasely.presentation.placement(...)` / `PresentationBuilder` → `PresentationRequest` (`.preload()` / `.display(...)`); the Cordova v6 bridge still calls `fetchPresentation`. +Some apps display a follow-up paywall after a successful purchase — a thank-you screen, a premium feature tour, a one-tap upsell, etc. **This is not part of the SDK contract**: it's just another presentation fetch with whatever placement ID you've configured on the Console (e.g. `"post_purchase"`, `"thank_you"`, `"premium_welcome"` — name it whatever you want, just match it in the dashboard). Native iOS/Android v6 build it with `PLYPresentationBuilder` / the `PLYPresentation { }` DSL; React Native v6 builds it with `Purchasely.presentation.placement(...)` → `PLYPresentationRequest`; Flutter v6 builds it with `PLYPresentationBuilder.placement(...)` → `PLYPresentationRequest` (`.preload()` / `.display(...)`); the Cordova v6 bridge still calls `fetchPresentation`. ### The audience-targeting gotcha -If the chained placement's audience targets users based on subscription state, **`synchronize()` must complete before the fetch**. Otherwise the fetch resolves against stale state and may return a `DEACTIVATED` (or wrong-fallback) presentation. +If the chained placement's audience targets users based on subscription state, that state must be fresh before the fetch — otherwise the fetch resolves against stale state and may return a `DEACTIVATED` (or wrong-fallback) presentation. -- On iOS, this is why the `synchronizeReceipt()` `await` matters. -- On Android v6, `synchronize(onSuccess = { … }, onError = { … })` refreshes the subscriptions cache before `onSuccess` — kick off the follow-up fetch from `onSuccess` so it resolves against fresh state. -- On React Native v6, `await Purchasely.synchronize()` (returns `Promise`) resolves once the native bridge confirms — `await` it before the follow-up `preload()`. -- On Flutter, `await synchronize()` resolves once the native bridge confirms; same trade-off as native. -- On Cordova v6, `synchronize(success, error)` reports completion; run the follow-up fetch from the success callback. +- **Purchase went through the paywall's action interceptor** (the recommended sequence above): resolving the interceptor with a success result already triggered the SDK's auto-synchronization. No extra `synchronize()` call is needed before the follow-up fetch. +- **Purchase happened outside the interceptor** (a fully custom sale screen, or [BYOS](byos.md)): there is no auto-sync. Call `Purchasely.synchronize()` yourself and wait for it to complete before fetching the follow-up placement — iOS/Android/Cordova accept `success/failure` (or `onSuccess`/`onError`) callbacks; React Native and Flutter `await` a `Future`/`Promise` that resolves once the native bridge confirms. ### Example chain (iOS) @@ -195,13 +177,16 @@ if let p = presentation, ### Example chain (Flutter) ```dart +// Only needed if the purchase happened outside the interceptor (e.g. BYOS) — +// skip this call when the purchase was resolved through the action interceptor. await Purchasely.synchronize(); -final request = PresentationBuilder + +final request = PLYPresentationBuilder .placement('YOUR_POST_PURCHASE_PLACEMENT_ID') .build(); final p = await request.preload(); -if (p.type == PresentationType.normal || p.type == PresentationType.fallback) { - await p.display(const Transition.fullScreen()); +if (p.type == PLYPresentationType.normal || p.type == PLYPresentationType.fallback) { + await p.display(const PLYTransition.fullScreen()); } ``` diff --git a/purchasely/references/concepts/paywall-actions.md b/purchasely/references/concepts/paywall-actions.md index 7f24b0b..0565a83 100644 --- a/purchasely/references/concepts/paywall-actions.md +++ b/purchasely/references/concepts/paywall-actions.md @@ -6,15 +6,15 @@ The **action interceptor** is a callback the SDK invokes when the user interacts ## The golden rule -**Every code path through the interceptor MUST resolve exactly once** — return a `PLYInterceptResult` / `InterceptResult` / string result (native iOS/Android v6, React Native v6, Flutter v6, Cordova v6). +**Every code path through the interceptor MUST resolve exactly once** — return a `PLYInterceptResult` (native iOS/Android v6, Flutter v6), a string result (React Native v6), or `Purchasely.InterceptResult` (Cordova v6). If a branch (early return, error catch, `switch default`, `try/catch`, etc.) skips it, the paywall UI freezes permanently — this is the #1 most common Purchasely bug across all platforms. If a branch resolves twice, behavior is undefined. -When in doubt, wrap the handler in a `try/finally` (or equivalent) that resolves the result on every path (native iOS/Android and Flutter v6 return `.notHandled` / `PLYInterceptResult.NOT_HANDLED` / `InterceptResult.notHandled`; React Native v6 returns `'notHandled'`; Cordova v6 returns or resolves `Purchasely.InterceptResult.notHandled`). +When in doubt, wrap the handler in a `try/finally` (or equivalent) that resolves the result on every path (iOS returns `.notHandled`; Android returns `PLYInterceptResult.NOT_HANDLED`; Flutter returns `PLYInterceptResult.notHandled`; React Native v6 returns `'notHandled'`; Cordova v6 returns or resolves `Purchasely.InterceptResult.notHandled`). ## `PLYPresentationAction` -Same set of actions on every platform; on **native iOS/Android v6, React Native v6, Flutter v6, and Cordova v6** each action gets its own interceptor and you return or resolve a result (`PLYInterceptResult` / `InterceptResult` / a string). +Same set of actions on every platform; on **native iOS/Android v6, React Native v6, Flutter v6, and Cordova v6** each action gets its own interceptor and you return or resolve a result (`PLYInterceptResult` on native iOS/Android and Flutter, a string on React Native, `Purchasely.InterceptResult` on Cordova). The result semantics are: @@ -43,7 +43,7 @@ Casing / type reference per platform: | iOS | `Purchasely.interceptAction(.purchase)` / `.restore` / `.login` / `.close` / `.navigate` / `.openPresentation` / `.promoCode` | | Android | Sealed class: `PLYPresentationAction.Purchase` / `.Restore` / `.Login` / `.Close` / `.Navigate` / `.OpenPresentation` / `.OpenPlacement` / `.PromoCode` | | React Native | String kinds passed to `interceptAction(kind, …)`: `'close'` / `'closeAll'` / `'login'` / `'navigate'` / `'purchase'` / `'restore'` / `'openPresentation'` / `'openPlacement'` / `'promoCode'` / `'webCheckout'` | -| Flutter | `PresentationActionKind.purchase` / `.restore` / `.login` / `.close` / `.navigate` / `.openPresentation` / `.promoCode` | +| Flutter | `PLYPresentationActionKind.purchase` / `.restore` / `.login` / `.close` / `.navigate` / `.openPresentation` / `.promoCode` | | Cordova | `Purchasely.PresentationAction` string values: `close` (`'close'`), `closeAll` (`'close_all'`), `login` (`'login'`), `navigate` (`'navigate'`), `purchase` (`'purchase'`), `restore` (`'restore'`), `openPresentation` (`'open_presentation'`), `openPlacement` (`'open_placement'`), `promoCode` (`'promo_code'`), `webCheckout` (`'web_checkout'`) | ## Registering the interceptor @@ -138,20 +138,20 @@ Remove with `Purchasely.removeActionInterceptor('login')` / `Purchasely.removeAl ### Flutter (Dart) -In v6 Flutter registers one interceptor **per action** and returns a `InterceptResult` (mirroring native iOS/Android): +In v6 Flutter registers one interceptor **per action** and returns a `PLYInterceptResult` (mirroring native iOS/Android): ```dart -Purchasely.interceptAction(PresentationActionKind.login, (info, payload) async { +Purchasely.interceptAction(PLYPresentationActionKind.login, (info, payload) async { final ok = await showLogin(); - return ok ? InterceptResult.success : InterceptResult.notHandled; + return ok ? PLYInterceptResult.success : PLYInterceptResult.notHandled; }); -Purchasely.interceptAction(PresentationActionKind.purchase, (info, payload) async { - return InterceptResult.notHandled; // Full mode lets the SDK run the purchase +Purchasely.interceptAction(PLYPresentationActionKind.purchase, (info, payload) async { + return PLYInterceptResult.notHandled; // Full mode lets the SDK run the purchase }); ``` -Remove with `Purchasely.removeInterceptor(PresentationActionKind.login)` / `Purchasely.removeAllInterceptors()`. +Remove with `Purchasely.removeActionInterceptor(PLYPresentationActionKind.login)` / `Purchasely.removeAllActionInterceptors()`. ### Cordova (JavaScript) @@ -179,7 +179,7 @@ Native iOS/Android v6, React Native v6, Flutter v6, and Cordova v6 return or res | Action | Full mode | Observer mode | |--------|-----------|---------------| -| `purchase` | `.notHandled` / `'notHandled'` — SDK runs the purchase. | Run your own billing flow, call `Purchasely.synchronize()` on success, then `.success` / `'success'` so the SDK doesn't re-run a purchase. | +| `purchase` | `.notHandled` / `'notHandled'` — SDK runs the purchase. | Run your own billing flow, then return `.success` / `'success'` so the SDK doesn't re-run a purchase. Returning a success result from the `purchase`/`restore` interceptor in Observer mode auto-synchronizes with Purchasely's servers — do **not** call `Purchasely.synchronize()` yourself here (see [observer-mode-post-purchase.md](observer-mode-post-purchase.md)). | | `restore` | `.notHandled` / `'notHandled'` — SDK restores. | Run your own restore, then `.success` / `.failed` / `'success'` / `'failed'`. | | `login` | App handles. SDK then re-fetches with the new user. | Same. | diff --git a/purchasely/references/concepts/presentation-cache.md b/purchasely/references/concepts/presentation-cache.md index eaa5a9e..000dd4f 100644 --- a/purchasely/references/concepts/presentation-cache.md +++ b/purchasely/references/concepts/presentation-cache.md @@ -6,7 +6,7 @@ Applies to: **iOS, Android, React Native, Flutter, Cordova**. ## The problem -Fetching a presentation on every display hits the network each time. (Native iOS/Android v6 fetch with `PLYPresentationBuilder` / the `PLYPresentation { }` DSL + `preload`; React Native v6 builds a request with `Purchasely.presentation.placement(id).build()` then `request.preload()`; Flutter v6 builds a request with `PresentationBuilder.placement(id).build()` then `request.preload()`; the method-based Cordova v6 bridge calls `Purchasely.fetchPresentationForPlacement(...)`.) If you display the same placement repeatedly (`onAppear` / `onViewWillAppear` firing multiple times, sheet/back navigation, recomposition, etc.), each fetch: +Fetching a presentation on every display hits the network each time. (Native iOS/Android v6 fetch with `PLYPresentationBuilder` / the `PLYPresentation { }` DSL + `preload`; React Native v6 builds a request with `Purchasely.presentation.placement(id).build()` then `request.preload()`; Flutter v6 builds a request with `PLYPresentationBuilder.placement(id).build()` then `request.preload()`; the method-based Cordova v6 bridge calls `Purchasely.fetchPresentationForPlacement(...)`.) If you display the same placement repeatedly (`onAppear` / `onViewWillAppear` firing multiple times, sheet/back navigation, recomposition, etc.), each fetch: 1. Round-trips to Purchasely servers. 2. **For flow placements**, accumulates a `flowSteps` entry in the SDK's internal `FlowsManager`. @@ -28,7 +28,7 @@ The cached presentation can become stale. Invalidate (clear all entries) when an Invalidation is intentionally coarse-grained (clear-all) because the SDK doesn't expose attribute→audience dependencies. -> On native iOS/Android v6 you can also preload once and display later without a second network call: keep the loaded `PLYPresentation` reference and call `presentation.display(from:)` / `loaded.display(context)` when the user acts. **React Native v6 and Flutter v6 do the same at the request level:** build the `PresentationRequest` once (`Purchasely.presentation.placement(id).build()` / `PresentationBuilder.placement(id).build()`), call `request.preload()` early, then reuse the *same* request and call `request.display()` when the user acts — no second fetch. This covers the common preload-early/display-late case without a custom cache; an app-side cache is still useful when you key by `placementId[/contentId]` across many call sites. +> On native iOS/Android v6 you can also preload once and display later without a second network call: keep the loaded `PLYPresentation` reference and call `presentation.display(from:)` / `loaded.display(context)` when the user acts. **React Native v6 and Flutter v6 do the same at the request level:** build the `PLYPresentationRequest` once (`Purchasely.presentation.placement(id).build()` / `PLYPresentationBuilder.placement(id).build()`), call `request.preload()` early, then reuse the *same* request and call `request.display()` when the user acts — no second fetch. This covers the common preload-early/display-late case without a custom cache; an app-side cache is still useful when you key by `placementId[/contentId]` across many call sites. ## Skeleton implementations @@ -72,7 +72,7 @@ object PresentationCache { ### React Native (TypeScript) ```ts -// Cache the built PresentationRequest so preload() + display() reuse the same one +// Cache the built PLYPresentationRequest so preload() + display() reuse the same one // (no second network fetch). Key by placementId[/contentId]. const cache = new Map(); @@ -136,7 +136,7 @@ fetchOrCached(placementId): # native iOS/Android v6: PLYPresentationBuilder.forPlacementId(placementId).build().preload() # / PLYPresentation { placementId(...) }.preload() # React Native v6: Purchasely.presentation.placement(placementId).build() → request.preload() - # Flutter v6: await PresentationBuilder.placement(placementId).build().preload() + # Flutter v6: await PLYPresentationBuilder.placement(placementId).build().preload() # Cordova v6 bridge: await Purchasely.fetchPresentationForPlacement(placementId) fresh = await preload(placementId) cache.set(placementId, fresh) diff --git a/purchasely/references/concepts/presentation-types.md b/purchasely/references/concepts/presentation-types.md index a25b737..a790e38 100644 --- a/purchasely/references/concepts/presentation-types.md +++ b/purchasely/references/concepts/presentation-types.md @@ -2,7 +2,7 @@ Applies to: **iOS, Android, React Native, Flutter, Cordova**. -Every fetched/preloaded presentation carries a `type` field telling you what the dashboard returned. **You must check the type before displaying** — calling `display(...)` on a `DEACTIVATED` presentation is undefined behaviour and a `CLIENT` presentation isn't a real paywall at all. Native iOS/Android, React Native and Flutter v6 obtain the presentation with `PLYPresentationBuilder` / the `PLYPresentation { }` DSL / `Purchasely.presentation....build()` / `PresentationBuilder` + `preload`; the method-based Cordova v6 bridge still calls `Purchasely.fetchPresentationForPlacement(...)`. +Every fetched/preloaded presentation carries a `type` field telling you what the dashboard returned. **You must check the type before displaying** — calling `display(...)` on a `DEACTIVATED` presentation is undefined behaviour and a `CLIENT` presentation isn't a real paywall at all. Native iOS/Android, React Native and Flutter v6 obtain the presentation with `PLYPresentationBuilder` / the `PLYPresentation { }` DSL / `Purchasely.presentation....build()` / `PLYPresentationBuilder` + `preload`; the method-based Cordova v6 bridge still calls `Purchasely.fetchPresentationForPlacement(...)`. ## The four types @@ -22,7 +22,7 @@ Every fetched/preloaded presentation carries a `type` field telling you what the | iOS | `PLYPresentationType.normal` / `.fallback` / `.deactivated` / `.client` | | Android | `PLYPresentationType.NORMAL` / `.FALLBACK` / `.DEACTIVATED` / `.CLIENT` | | React Native | `PLYPresentationType.NORMAL` / `.FALLBACK` / `.DEACTIVATED` / `.CLIENT` | -| Flutter | `PresentationType.normal` / `.fallback` / `.deactivated` / `.client` | +| Flutter | `PLYPresentationType.normal` / `.fallback` / `.deactivated` / `.client` | | Cordova | String values: `'NORMAL'`, `'FALLBACK'`, `'DEACTIVATED'`, `'CLIENT'` | ## Fetch + guard pattern @@ -99,7 +99,7 @@ switch (presentation.type) { ### Flutter (Dart) ```dart -final request = PresentationBuilder +final request = PLYPresentationBuilder .placement('PREMIUM_PAYWALL') .build(); @@ -110,15 +110,15 @@ if (presentation == null) { } switch (presentation.type) { - case PresentationType.normal: - case PresentationType.fallback: - // display(...) resolves at dismiss with a PresentationOutcome (required for Flows). - final outcome = await request.display(const Transition.fullScreen()); + case PLYPresentationType.normal: + case PLYPresentationType.fallback: + // display(...) resolves at dismiss with a PLYPresentationOutcome (required for Flows). + final outcome = await request.display(const PLYTransition.fullScreen()); handleResult(outcome); break; - case PresentationType.deactivated: + case PLYPresentationType.deactivated: return; - case PresentationType.client: + case PLYPresentationType.client: showCustomPaywall(presentation.plans); break; } @@ -169,8 +169,8 @@ Use `display()` / bridge `presentPresentation(...)` by default. Switch to contai |----------|-----------------|-----------------------| | iOS | `presentation.display(from:)` | `presentation.controller` (UIKit) / `presentation.swiftUIView` (SwiftUI) | | Android | `loaded.display(activity)` | `loaded.buildView(context) { outcome -> }` or `loaded.getFragment { outcome -> }` | -| React Native | `request.display()` (on the built `PresentationRequest`) | `` component | -| Flutter | `request.display(const Transition.fullScreen())` | `PLYPresentationView(request: ...)` widget | +| React Native | `request.display()` (on the built `PLYPresentationRequest`) | `` component | +| Flutter | `request.display(const PLYTransition.fullScreen())` | `PLYPresentationView(request: ...)` widget | | Cordova | `Purchasely.presentPresentation(presentation, displayMode, backgroundColor, success, error)` | no general-purpose inline bridge in the public JS API | ## See also diff --git a/purchasely/references/concepts/promotional-offers.md b/purchasely/references/concepts/promotional-offers.md index 913ac68..2fb3858 100644 --- a/purchasely/references/concepts/promotional-offers.md +++ b/purchasely/references/concepts/promotional-offers.md @@ -102,11 +102,11 @@ Purchasely.interceptAction { info, purchase -> val offerId = purchase.subscriptionOffer?.offerId val offerToken = purchase.subscriptionOffer?.offerToken - // Trigger your own Google Play Billing purchase with offerToken, then synchronize + // Trigger your own Google Play Billing purchase with offerToken // … - Purchasely.synchronize(onSuccess = { }, onError = { }) - PLYInterceptResult.SUCCESS // app handled the purchase + PLYInterceptResult.SUCCESS // app handled the purchase — returning success auto-synchronizes + // with Purchasely; do NOT call Purchasely.synchronize() yourself here } ``` @@ -134,8 +134,8 @@ Purchasely.interceptAction('purchase', async (info, payload) => { const ok = await yourBillingSystem.purchase(/* fields above */); if (!ok) return 'failed'; - await Purchasely.synchronize(); // upload the new receipt after a successful purchase - return 'success'; // app handled the purchase + return 'success'; // app handled the purchase — returning success auto-synchronizes with + // Purchasely; do NOT call Purchasely.synchronize() yourself here // Observer mode does not auto-close; dismiss with request.close() after this resolves. }); ``` @@ -158,25 +158,20 @@ Purchasely.interceptAction(Purchasely.PresentationAction.purchase, async (info, // to get { identifier, signature, keyIdentifier, timestamp } and pass them to your purchase flow const ok = await yourBillingSystem.purchase(/* fields above */); - if (!ok) return Purchasely.InterceptResult.failed; - - return new Promise(function (resolve) { - Purchasely.synchronize( - function () { resolve(Purchasely.InterceptResult.success); }, - function () { resolve(Purchasely.InterceptResult.failed); }, - ); - }); + // Returning success auto-synchronizes with Purchasely — do NOT call + // Purchasely.synchronize() yourself here. + return ok ? Purchasely.InterceptResult.success : Purchasely.InterceptResult.failed; // Observer mode does not auto-close; dismiss with closePresentation() after this resolves. }); ``` -#### Flutter (Dart) — `PurchasePayload` from the per-action interceptor +#### Flutter (Dart) — `PLYPurchasePayload` from the per-action interceptor -In v6 Flutter mirrors the native per-action model: register `Purchasely.interceptAction` for the purchase kind and return an `InterceptResult`. `PurchasePayload` carries real Dart model objects (`PLYPlan`, nullable `PLYSubscriptionOffer`, nullable `PLYPromoOffer`), so read properties instead of indexing maps. +In v6 Flutter mirrors the native per-action model: register `Purchasely.interceptAction` for the purchase kind and return a `PLYInterceptResult`. `PLYPurchasePayload` carries real Dart model objects (`PLYPlan`, nullable `PLYSubscriptionOffer`, nullable `PLYPromoOffer`), so read properties instead of indexing maps. ```dart -Purchasely.interceptAction(PresentationActionKind.purchase, (info, payload) async { - if (payload is! PurchasePayload) return InterceptResult.notHandled; +Purchasely.interceptAction(PLYPresentationActionKind.purchase, (info, payload) async { + if (payload is! PLYPurchasePayload) return PLYInterceptResult.notHandled; // Cross-store generic fields final storeProductId = payload.plan?.productId; @@ -200,14 +195,13 @@ Purchasely.interceptAction(PresentationActionKind.purchase, (info, payload) asyn googleOfferToken: googleOfferToken, // appleSignature: ..., ); - if (!ok) return InterceptResult.failed; - - // Upload the new receipt to Purchasely only after a successful purchase: - await Purchasely.synchronize(); + if (!ok) return PLYInterceptResult.failed; // Observer mode does not auto-close; dismiss after the handler resolves. // Call presentation.close() from your post-resolution success callback. - return InterceptResult.success; // app handled the purchase + return PLYInterceptResult.success; // app handled the purchase — returning success + // auto-synchronizes with Purchasely; do NOT call + // Purchasely.synchronize() yourself here }); ``` diff --git a/purchasely/references/concepts/rendering-engine.md b/purchasely/references/concepts/rendering-engine.md new file mode 100644 index 0000000..e4ca104 --- /dev/null +++ b/purchasely/references/concepts/rendering-engine.md @@ -0,0 +1,74 @@ +# Rendering Engine — How Purchasely Renders a Screen + +Applies to: **iOS and Android native paywall rendering**. React Native, Flutter and Cordova paywalls are rendered by whichever native engine (iOS or Android) is hosting them — there is no cross-platform rendering code to configure. + +A Screen Composer paywall arrives from the backend as JSON and is turned into on-screen UI by a native rendering engine. Knowing how that engine decodes, caches and renders a screen is useful when debugging blank paywalls, missing components, stale images, or silent Lottie failures. + +## iOS (UIKit, current engine) + +The JSON payload decodes into a recursive tree of `PLYComponent` — a Swift enum covering the **13 component types** a Screen can contain (containers, labels, images, buttons, video, carousel, Lottie, etc.). Each node in that tree lazily builds (or returns a cached) UIKit view via a `view()` call; the whole tree is built recursively in one synchronous pass when the presentation is configured. + +### Tolerant decoding (`Safe`) + +Most fields decode through a `Safe` wrapper: a malformed value degrades to `nil` for that one field instead of failing the enclosing decode, so one bad node doesn't take down the rest of the screen. This tolerance does **not** apply everywhere — three exceptions matter for debugging: + +| Exception | Behavior when malformed | +|-----------|--------------------------| +| Root component (unrecognized `type`) | The **entire paywall** fails to decode — the root is the one node that isn't wrapped in `Safe` | +| Scroll container content / direction | Not tolerant — a malformed value fails that container | +| Video / Lottie animation URLs | Not tolerant — a malformed URL fails that field, not just degrades | + +Everything else (colors, gradients, enum strings, nested styles) silently drops to a default or `nil` on a bad value, with no error surfaced to the Screen author beyond "the field didn't apply." + +### View memoization + +Each component memoizes its built UIKit view behind a **weak** reference — first call builds it, later calls on the same model instance return the cached view. Only the currently-displayed presentation holds a **strong** reference to the root view, which is what keeps the whole tree alive across in-place reconfigures (e.g. rotation) without leaking across a full re-fetch. + +### Image cache + +- **Memory cache** — holds compressed image *data*, not decoded `UIImage`s. +- **Disk cache** — a dedicated `URLCache` at `10 MB` memory / `100 MB` disk, isolated from `URLCache.shared` so it never competes with the host app's own networking. +- **Request dedup** — concurrent requests for the same URL are coalesced. + +Gotchas: + +- **DEBUG builds on iOS 18.4 / 18.5**: the session is forced to `.ephemeral` on that specific OS/build-config combination, which ignores the configured `URLCache` entirely — disk persistence silently stops applying. Release builds and other OS versions are unaffected. +- **Cache hits decode synchronously on the main thread.** A memory/disk cache hit still decodes the image data into a bitmap on the main thread every time that state is applied — on a screen with many cached images this can show up as UI hitches, not just cold-load latency. + +### Lottie bridge + +The SDK never links `lottie-ios` — it resolves an integrator-supplied `PLYLottieBridge` class at runtime via `NSClassFromString("PLYLottieBridge")`. If the class is missing or doesn't implement the expected bridge methods, the Lottie component **renders nothing** — no crash, no placeholder, only an error log. See [lottie-animations.md](lottie-animations.md) for the bridge implementation. + +### Known rendering bugs (6.0.0) + +Useful when debugging a specific symptom against this SDK version: + +| Symptom | Cause | +|---------|-------| +| Video ignores `"autoplay": false` | The field is decoded but never read — the player always calls `.play()` unconditionally | +| Spinner stuck after canceling a purchase | Happens when the `purchase` action is configured on **both** a container and a child label — one tap fires two `PURCHASE_TAPPED` events. Configure the purchase action once, on the element that owns the loader | + +### Future: SwiftUI renderer + +A SwiftUI rendering engine is planned but **not started**. UIKit remains the default and only shipping engine — there is no integrator-facing impact today, and no timeline to plan around yet. + +## Android (Views, current engine) + +Android renders paywalls with plain **Android Views / Fragments** — there is no Jetpack Compose rendering path in 6.0.1. + +### Fat-AAR distribution + +Internal modules (`:common`, `:network`, `:storage`, etc.) are folded into the single published artifact, `io.purchasely:core` — there is no new Maven coordinate to add, and every public `io.purchasely.*` FQN stays stable across the modularization. The on-disk storage format is also unchanged, so upgrading does not put cached data or stored subscriptions at risk. + +A Compose rendering engine is in active development for a future release — don't rely on it or design around it yet. + +## Events & rendering + +Since **6.0.0-rc.3**, `PRESENTATION_VIEWED` is protected from the SDK's local event-eviction: the local event cap was raised from 100 to 200, and `PRESENTATION_VIEWED` is exempted from FIFO eviction specifically. Builds before rc.3 could silently drop this event under high-volume Flows (many steps viewed in quick succession) once the local cap was hit. + +## See also + +- [lottie-animations.md](lottie-animations.md) — Lottie bridge setup (iOS `PLYLottieBridge`, Android `PLYLottieInterface`) +- [presentation-cache.md](presentation-cache.md) — app-side presentation caching, separate from the engine's internal image cache +- [presentation-types.md](presentation-types.md) — guarding `NORMAL` / `FALLBACK` / `DEACTIVATED` before display +- [../troubleshooting/common-issues.md](../troubleshooting/common-issues.md) — blank paywall / frozen UI diagnostic trees diff --git a/purchasely/references/concepts/running-modes.md b/purchasely/references/concepts/running-modes.md index 376a9c1..9fe2c68 100644 --- a/purchasely/references/concepts/running-modes.md +++ b/purchasely/references/concepts/running-modes.md @@ -30,6 +30,8 @@ Purchasely { } ``` +> **Other v6 defaults flipped to `true`.** Unlike `runningMode` (default flips to Observer), two unrelated v6 flags default to `true`: `allowCampaigns` (campaign display) and `allowDeeplink` (deeplink presentations) — both were `false`-by-default (or governed by a single legacy flag) pre-v6. See [campaigns.md](campaigns.md#sdk-setup--gating-campaign-display) for the full per-platform breakdown and the migration note. + ## The two modes | Mode | Description | When to use | @@ -113,9 +115,9 @@ await Purchasely.builder('YOUR_API_KEY') import 'package:purchasely_flutter/purchasely_flutter.dart'; await PurchaselyBuilder.apiKey('YOUR_API_KEY') - .runningMode(RunningMode.full) // or RunningMode.observer — default is observer in v6 - .storekitVersion(StorekitVersion.storeKit2) - .logLevel(LogLevel.warn) + .runningMode(PLYRunningMode.full) // or PLYRunningMode.observer — default is observer in v6 + .storekitVersion(PLYStorekitVersion.storeKit2) + .logLevel(PLYLogLevel.warn) .stores([PLYStore.google]) .start(); ``` @@ -137,7 +139,7 @@ Purchasely.start( ); ``` -> **Cross-platform note.** React Native, Flutter, and Cordova are on the v6 API (default Observer), in the same v6 group as native iOS & Android. React Native uses the builder (`Purchasely.builder('key')....start()`) and string running modes (`'full'` / `'observer'`); Flutter uses `PurchaselyBuilder.apiKey(...)....start()` and the `RunningMode` enum; Cordova keeps method-based `start(...)` but now takes a single options object with `Purchasely.RunningMode.full` / `.observer`. Always confirm the exact plugin signature in that platform's integration reference and in [`sdk-versions.md`](../sdk-versions.md). +> **Cross-platform note.** React Native, Flutter, and Cordova are on the v6 API (default Observer), in the same v6 group as native iOS & Android. React Native uses the builder (`Purchasely.builder('key')....start()`) and string running modes (`'full'` / `'observer'`); Flutter uses `PurchaselyBuilder.apiKey(...)....start()` and the `PLYRunningMode` enum; Cordova keeps method-based `start(...)` but now takes a single options object with `Purchasely.RunningMode.full` / `.observer`. Always confirm the exact plugin signature in that platform's integration reference and in [`sdk-versions.md`](../sdk-versions.md). ## Log Levels @@ -155,7 +157,7 @@ Enum names vary slightly by platform: | iOS | `LogLevel.debug` / `.info` / `.warn` / `.error` | | Android | `LogLevel.DEBUG` / `.INFO` / `.WARN` / `.ERROR` | | React Native | `.logLevel('debug')` / `'info'` / `'warn'` / `'error'` (string on the v6 builder) | -| Flutter | `LogLevel.debug` / `.info` / `.warn` / `.error` | +| Flutter | `PLYLogLevel.debug` / `.info` / `.warn` / `.error` | | Cordova | `Purchasely.LogLevel.DEBUG` / `.INFO` / `.WARN` / `.ERROR` | > On native Android v6, `Purchasely.logcatEnabled` controls Logcat output independently of `logLevel`, and custom loggers receive all messages regardless of level. diff --git a/purchasely/references/concepts/subscription-checks.md b/purchasely/references/concepts/subscription-checks.md index c52943b..4c7a8b7 100644 --- a/purchasely/references/concepts/subscription-checks.md +++ b/purchasely/references/concepts/subscription-checks.md @@ -155,11 +155,11 @@ Purchasely.restoreAllProducts( > On iOS, restore may prompt the user to sign in to the App Store. On Android, it queries Google Play Billing locally (no prompt). The user experience differs; account for that in your UI copy. -> **Observer mode:** if you handle restores yourself, intercept the `restore` action in the [paywall actions interceptor](paywall-actions.md), run your own restore flow, then call `Purchasely.synchronize()` and resolve the interceptor (native iOS/Android v6 and Flutter v6: return `PLYInterceptResult.success` / `SUCCESS` / `InterceptResult.success`; React Native v6: return `'success'` / `'failed'`; Cordova v6: return or resolve `Purchasely.InterceptResult.success` / `.failed`). +> **Observer mode:** if you handle restores yourself, intercept the `restore` action in the [paywall actions interceptor](paywall-actions.md), run your own restore flow, then resolve the interceptor (iOS: return `PLYInterceptResult.success` / `.failed`; Android: `PLYInterceptResult.SUCCESS` / `.FAILED`; Flutter: `PLYInterceptResult.success` / `.failed`; React Native v6: return `'success'` / `'failed'`; Cordova v6: return or resolve `Purchasely.InterceptResult.success` / `.failed`). Returning a success result auto-synchronizes with Purchasely's servers — do **not** call `Purchasely.synchronize()` yourself inside the interceptor (see [observer-mode-post-purchase.md](observer-mode-post-purchase.md)). Manual `synchronize()` is only for restores handled entirely outside the interceptor (e.g. a custom sale screen or [BYOS](byos.md)). ## Close paywalls programmatically -After a manual gate-then-purchase flow, dismiss the paywall after resolving the action interceptor. Native iOS/Android use `Purchasely.closeAllScreens()`; React Native v6 dismisses via `request.close()` on the `PLYPresentationRequest` you built; Flutter v6 dismisses via `presentation.close()` on the loaded `Presentation`; Cordova v6 uses `Purchasely.closePresentation()` on the public JS bridge. See [observer-mode-post-purchase.md](observer-mode-post-purchase.md) for exact per-platform ordering. +After a manual gate-then-purchase flow, dismiss the paywall after resolving the action interceptor. Native iOS/Android use `Purchasely.closeAllScreens()`; React Native v6 dismisses via `request.close()` on the `PLYPresentationRequest` you built; Flutter v6 dismisses via `presentation.close()` on the loaded `PLYPresentation`; Cordova v6 uses `Purchasely.closePresentation()` on the public JS bridge. See [observer-mode-post-purchase.md](observer-mode-post-purchase.md) for exact per-platform ordering. ## Anti-patterns diff --git a/purchasely/references/concepts/subscription-management.md b/purchasely/references/concepts/subscription-management.md index b838a68..a6a5d33 100644 --- a/purchasely/references/concepts/subscription-management.md +++ b/purchasely/references/concepts/subscription-management.md @@ -16,6 +16,21 @@ The native subscription management page lets the user: Purchasely does not gate this — the SDK simply opens the OS-native URL. +## The old SDK-native subscription screen is gone in v6 + +Earlier SDK versions also shipped a **Purchasely-rendered** subscriptions screen (a UI built by the SDK itself, distinct from the OS-native page this doc covers). In v6 that in-SDK screen was **removed**, not deprecated to a no-op: + +| Platform | Removed method | Notes | +|----------|-----------------|-------| +| Android | `Purchasely.subscriptionsFragment()` | Removed entirely — the method no longer exists. | +| Flutter | `Purchasely.presentSubscriptions()` | Removed entirely — no drop-in replacement. | +| React Native | `Purchasely.presentSubscriptions()` | Removed entirely — no drop-in replacement. | +| iOS | *(never had one)* | iOS never shipped a `presentSubscriptions()`-equivalent; the removed iOS API is the general-purpose `showController(_:type:from:)` / `PLYUIControllerType`, unrelated to the subscriptions screen specifically. | + +`displaySubscriptionCancellationInstruction()` (the cancellation-survey UI) is likewise **removed** on **Android and Flutter** in v6 — not a no-op. + +**Replacement:** there is no drop-in SDK screen. Build your own subscription-management UI from `Purchasely.userSubscriptions()` / `Purchasely.userSubscriptionsHistory()` (see [subscription-checks.md](subscription-checks.md)), and use the OS-native deeplinks documented below for the actual cancel/upgrade/downgrade actions — which is what Apple and Google require anyway (see [Anti-patterns](#anti-patterns)). + ## iOS ### iOS 15+ (recommended) diff --git a/purchasely/references/concepts/user-attributes-targeting.md b/purchasely/references/concepts/user-attributes-targeting.md index 92e44eb..76323d8 100644 --- a/purchasely/references/concepts/user-attributes-targeting.md +++ b/purchasely/references/concepts/user-attributes-targeting.md @@ -19,7 +19,7 @@ User attributes are key-value pairs the SDK forwards to Purchasely servers. They Setting an attribute is **not** an event the SDK reacts to. `setUserAttribute(...)` saves the value immediately (and persists it across sessions in the SDK's own disk cache, so you only have to set it once), but it does **not** re-run, re-fetch, or re-evaluate any placement or campaign on its own. The new value is applied to audience matching only on the **next call** that resolves a screen: -- **Placements:** the next presentation fetch/build picks up the value (native v6 `PLYPresentationBuilder.forPlacementId(...).build()` / `.preload()`, React Native v6 `Purchasely.presentation.placement(...).build()` → `preload()` / `display(...)`, Flutter v6 `PresentationBuilder.placement(...).build()` → `preload()` / `display(...)`, Cordova v6 `fetchPresentation(...)`). So set the attribute **before** you fetch the placement. +- **Placements:** the next presentation fetch/build picks up the value (native v6 `PLYPresentationBuilder.forPlacementId(...).build()` / `.preload()`, React Native v6 `Purchasely.presentation.placement(...).build()` → `preload()` / `display(...)`, Flutter v6 `PLYPresentationBuilder.placement(...).build()` → `preload()` / `display(...)`, Cordova v6 `fetchPresentation(...)`). So set the attribute **before** you fetch the placement. - **Campaigns:** the audience is evaluated when the campaign trigger resolves — for the default `APP_STARTED` trigger this happens **shortly after SDK start** (or when you flip [`allowCampaigns(true)`](campaigns.md#sdk-setup--gating-campaign-display) if you gated it). Setting the attribute afterwards has no effect on that already-resolved trigger. **Consequence for a campaign whose audience is built on a custom attribute:** if the attribute is set *after* the SDK has already evaluated campaigns at start, the audience will **not** match on the **first launch**. Because the value is persisted, it is present at the next cold start, so the campaign matches **from the next session** onward — which is why this kind of bug looks intermittent ("it worked once"). @@ -102,6 +102,17 @@ Purchasely.setUserAttributeWithDate('signup_date', user.signupDate.toISOStrin Purchasely.setUserAttributeWithBoolean('is_power_user', user.isPowerUser); ``` +## v6 gotcha: OneSignal built-in attribute renamed + +If the app forwards a OneSignal player/subscription id to Purchasely for audience targeting, the built-in attribute was **renamed in v6** — the old key is gone, not aliased: + +| | v5 | v6 | +|---|-----|-----| +| SDK-side attribute | `oneSignalPlayerId` | `oneSignalExternalId` / `oneSignalUserId` | +| Backend / dashboard key | `onesignal_player_id` | `onesignal_external_id` | + +**The gotcha:** any audience or paywall-targeting rule still built on the old `onesignal_player_id` key **stops receiving data silently** after upgrading — there's no error, no warning, the attribute simply stops updating and the audience quietly goes stale. If OneSignal-based targeting used to work and now looks "frozen" post-v6-upgrade, this rename is the first thing to check: update both the SDK call site (`oneSignalExternalId` / `oneSignalUserId`) and the Console audience rule (`onesignal_external_id`). + ## Reading and removing attributes | Action | iOS | Android | RN | Flutter | Cordova | diff --git a/purchasely/references/concepts/web-checkout.md b/purchasely/references/concepts/web-checkout.md new file mode 100644 index 0000000..d68a7ed --- /dev/null +++ b/purchasely/references/concepts/web-checkout.md @@ -0,0 +1,40 @@ +# Web Checkout — Stripe Payment Links + +Applies to: **iOS, Android, React Native, Flutter, Cordova**. + +Web Checkout routes a purchase to a **Stripe Payment Link** opened in the device browser instead of the store's in-app purchase flow — typically used to steer a targeted audience (e.g. a specific store country) to web billing. + +Official doc: [Web Checkout](https://docs.purchasely.com/docs/web-checkout). + +## Flow + +1. The user taps a paywall button configured with the `webCheckout` action. +2. The SDK opens the plan's **Stripe Payment Link** in the system browser. +3. The user completes payment on Stripe's hosted page. + +Targeting who sees a Web Checkout button is done the normal way — build a Console audience on `Store country` / `Store name` (e.g. serve Web Checkout only to `US` App Store or Play Store users) and attach it to the paywall or the button's visibility rule. + +## Interceptor + +`webCheckout` is an action kind like any other — register a per-action interceptor for it exactly as you would for `purchase` or `open_screen`. See [paywall-actions.md](paywall-actions.md) for the general per-action interceptor contract (`interceptAction` / `PLYInterceptResult` / string result depending on platform). + +## Events + +Web Checkout has its own dedicated SDK/UI events, separate from the regular purchase event stream: + +| Event | Fires when | +|-------|------------| +| `WEB_CHECKOUT_TAPPED` | The user taps the Web Checkout button | +| `WEB_CHECKOUT_OPENED_IN_WEB_BROWSER` | The Stripe Payment Link successfully opens in the browser | +| `WEB_CHECKOUT_ERROR` | The link fails to open or Stripe reports an error | +| `WEB_CHECKOUT_TIMED_OUT` | The flow times out waiting for a result | + +## Flutter bridge gotcha + +The iOS `webCheckout` interceptor payload changed format between SDK versions — the raw action-kind value moved from a **legacy `Int` raw value** to a **string**. The Flutter bridge tolerates both formats. If `webCheckout` interception appears to be a no-op on iOS after bumping the native SDK, check whether the Flutter bridge is still matching against the old integer format. + +## See also + +- [paywall-actions.md](paywall-actions.md) — per-action interceptor contract shared by every action kind, including `webCheckout` +- [user-attributes-targeting.md](user-attributes-targeting.md) — building audiences on `Store country` / `Store name` +- [monthly-commitment.md](monthly-commitment.md) — another store-billing variant surfaced through paywall actions diff --git a/purchasely/references/cordova/integration.md b/purchasely/references/cordova/integration.md index 0cfc21f..f1951eb 100644 --- a/purchasely/references/cordova/integration.md +++ b/purchasely/references/cordova/integration.md @@ -1,6 +1,6 @@ # Cordova Integration -> **Cross-platform reference.** This file covers Cordova-specific syntax for the **v6** SDK (`6.0.0-rc.1`). Many concepts (Observer-mode post-purchase flow, presentation type guard, presentation cache, programmatic purchases, audience-targeting attributes, GDPR consent, subscription checks) are **universal across iOS / Android / RN / Flutter / Cordova** and live in `../concepts/`. Load: +> **Cross-platform reference.** This file covers Cordova-specific syntax for the **v6** SDK (`6.0.0-rc.3`). Many concepts (Observer-mode post-purchase flow, presentation type guard, presentation cache, programmatic purchases, audience-targeting attributes, GDPR consent, subscription checks) are **universal across iOS / Android / RN / Flutter / Cordova** and live in `../concepts/`. Load: > > - [`../concepts/running-modes.md`](../concepts/running-modes.md) — Full vs Observer + log levels > - [`../concepts/paywall-actions.md`](../concepts/paywall-actions.md) — per-action `interceptAction` + `InterceptResult` rules @@ -11,24 +11,26 @@ > - [`../concepts/user-attributes-targeting.md`](../concepts/user-attributes-targeting.md) — audience targeting + GDPR consent > - [`../concepts/privacy-settings.md`](../concepts/privacy-settings.md) — `revokeDataProcessingConsent` and privacy purposes > - [`../concepts/subscription-checks.md`](../concepts/subscription-checks.md) — gating premium content, restore purchases -> - [`../sdk-versions.md`](../sdk-versions.md) — latest stable versions (pin to **6.0.0-rc.1** for Cordova) +> - [`../sdk-versions.md`](../sdk-versions.md) — latest stable versions (pin to **6.0.0-rc.3** for Cordova) > - [`migration-v6.md`](migration-v6.md) — v5 → v6 migration mapping for Cordova > **v6 keeps a method-based JS API — but the surface changed.** Unlike native iOS/Android and the React Native / Flutter SDKs, the Cordova plugin does **not** introduce a builder API — the native bridges were rewired to the v6 SDKs behind `cordova.exec` actions. Most methods keep their name and signature, but there are **three breaking surfaces**: `start()` now takes a **single options object** (was positional); the action interceptor is now **per-action** (`interceptAction(kind, handler)` returning an `InterceptResult` — `setPaywallActionInterceptor` + `onProcessAction` were **removed**); and the presentation `isFullscreen` boolean became a **display mode**. Smaller changes: default running mode is now **Observer**, deeplinks use `allowDeeplink` / `handleDeeplink` (+ new `allowCampaigns`), the default dismiss handler is `setDefaultPresentationDismissHandler`, `synchronize` reports completion, and `presentSubscriptions` / `presentProductWithIdentifier` / `presentPlanWithIdentifier` / `showPresentation` / `hidePresentation` were **removed**. ## Installation -Requirements: iOS 13.4+, Android minSdk 23, compileSdk 36. Pin all packages to **6.0.0-rc.1** (see [`../sdk-versions.md`](../sdk-versions.md)). The `6.0.0-rc.1` plugin pulls the **6.0.0-rc.2 native SDKs** (iOS `Purchasely`, Android `io.purchasely:core`). +Requirements: iOS 13.4+, Android minSdk 23, compileSdk 36. Pin all packages to **6.0.0-rc.3** (see [`../sdk-versions.md`](../sdk-versions.md)). The `6.0.0-rc.3` plugin pulls the **6.0.0-rc.3 native SDKs** (iOS `Purchasely`, Android `io.purchasely:core` — both confirmed pinned in `plugin.xml` at the published tag). + +> **npm's `latest` dist-tag still points to `5.7.3`.** `6.0.0-rc.3` is published under the `next` dist-tag, so `cordova plugin add @purchasely/cordova-plugin-purchasely` with no version pulls the old v5 plugin. Always install an explicit version (or `--tag next`). ```bash # Core plugin -cordova plugin add @purchasely/cordova-plugin-purchasely@6.0.0-rc.1 +cordova plugin add @purchasely/cordova-plugin-purchasely@6.0.0-rc.3 # Google Play — required if targeting Google Play Store -cordova plugin add @purchasely/cordova-plugin-purchasely-google@6.0.0-rc.1 +cordova plugin add @purchasely/cordova-plugin-purchasely-google@6.0.0-rc.3 ``` -**CRITICAL: All Purchasely packages must be at the exact same version.** A stray `6.0.0` (release) outranks `6.0.0-rc.1` in Gradle and silently upgrades `io.purchasely:core`, causing a runtime `NoSuchMethodError`. There is **no video player plugin on Cordova**. +**CRITICAL: All Purchasely packages must be at the exact same version.** A stray `6.0.0` (release) outranks `6.0.0-rc.3` in Gradle and silently upgrades `io.purchasely:core`, causing a runtime `NoSuchMethodError`. There is **no video player plugin on Cordova**. ### Android Setup @@ -218,14 +220,9 @@ Purchasely.interceptAction(Purchasely.PresentationAction.purchase, function(info var storeProductId = parameters.plan.productId; return MyPurchaseSystem.purchase(storeProductId).then(function(ok) { - if (!ok) return Purchasely.InterceptResult.failed; - - return new Promise(function(resolve) { - Purchasely.synchronize( - function() { resolve(Purchasely.InterceptResult.success); }, - function() { resolve(Purchasely.InterceptResult.failed); } - ); - }); + // Resolving with `success` auto-synchronizes the receipt — do not call + // Purchasely.synchronize() here. + return ok ? Purchasely.InterceptResult.success : Purchasely.InterceptResult.failed; }); }); @@ -234,7 +231,7 @@ function onPurchaseSuccess() { } ``` -`Purchasely.synchronize(success, error)` now reports completion (the v5 fire-and-forget behavior is gone); calling `Purchasely.synchronize()` with no arguments still works. +`Purchasely.synchronize(success, error)` now reports completion (the v5 fire-and-forget behavior is gone); calling `Purchasely.synchronize()` with no arguments still works. Reserve manual `synchronize()` calls for purchases processed **outside** the interceptor flow (a "Restore Purchases" button, a client-side/BYOS presentation) — resolving `.purchase` / `.restore` with `success` already synchronizes automatically. ## Programmatic Purchases diff --git a/purchasely/references/cordova/migration-v6.md b/purchasely/references/cordova/migration-v6.md index 063e4b6..4c503d1 100644 --- a/purchasely/references/cordova/migration-v6.md +++ b/purchasely/references/cordova/migration-v6.md @@ -3,10 +3,10 @@ > **In-repo migration guide.** This is the Cordova-specific v5 → v6 mapping for the > Purchasely plugin. The companion integration reference is > [`integration.md`](./integration.md); cross-platform concepts live in -> [`../concepts/`](../concepts/). Pin to `6.0.0-rc.1` (see [`../sdk-versions.md`](../sdk-versions.md)). +> [`../concepts/`](../concepts/). Pin to `6.0.0-rc.3` (see [`../sdk-versions.md`](../sdk-versions.md)). -The Cordova plugin v6 (`6.0.0-rc.1`) wraps the **Purchasely 6.0 native SDKs** (iOS -`Purchasely 6.0.0-rc.2`, Android `io.purchasely:core 6.0.0-rc.2`). Unlike the React Native / +The Cordova plugin v6 (`6.0.0-rc.3`) wraps the **Purchasely 6.0 native SDKs** (iOS +`Purchasely 6.0.0-rc.3`, Android `io.purchasely:core 6.0.0-rc.3`). Unlike the React Native / Flutter v6 plugins — which introduced a builder API — the **Cordova JavaScript surface stays method-based**: the native bridges were rewired to the v6 SDKs behind the existing `cordova.exec` actions. Most methods keep their name and signature, but there are **three @@ -34,7 +34,7 @@ isFullscreen closePaywall ``` > The plugin version itself (`@purchasely/cordova-plugin-purchasely`) is the clearest signal: -> `5.7.x` = v5, `6.0.0-rc.1` = v6. +> `5.7.x` = v5, `6.0.0-rc.3` = v6. --- @@ -70,10 +70,14 @@ isFullscreen closePaywall ## 1. Update the plugins ```bash -cordova plugin add @purchasely/cordova-plugin-purchasely@6.0.0-rc.1 -cordova plugin add @purchasely/cordova-plugin-purchasely-google@6.0.0-rc.1 +cordova plugin add @purchasely/cordova-plugin-purchasely@6.0.0-rc.3 +cordova plugin add @purchasely/cordova-plugin-purchasely-google@6.0.0-rc.3 ``` +> npm's `latest` dist-tag still resolves to `5.7.3` — `6.0.0-rc.3` is published under the `next` +> dist-tag. Always give an explicit version (as above) or `--tag next`; a bare `cordova plugin add +> @purchasely/cordova-plugin-purchasely` silently installs v5. + Both plugins must be on the **same** version. Minimum OS: **iOS 13.4**, **Android API 23** (`compileSdk 36`). There is no video player plugin on Cordova. @@ -252,6 +256,13 @@ Purchasely.synchronize(function(ok) {}, function(error) {}); `Purchasely.synchronize()` with no arguments still works. +> **Interceptor guidance.** Resolving a `.purchase` / `.restore` interceptor with `success` +> already **auto-synchronizes** the receipt — do not call `Purchasely.synchronize()` from inside +> that handler (see "Observer Mode — Processing Transactions Yourself" in +> [`integration.md`](./integration.md)). Reserve manual `synchronize()` calls for purchases +> processed **outside** the interceptor flow (a "Restore Purchases" button, a client-side/BYOS +> presentation). + --- ## 9. New exported constants diff --git a/purchasely/references/flutter/integration.md b/purchasely/references/flutter/integration.md index 73c997f..a8210fc 100644 --- a/purchasely/references/flutter/integration.md +++ b/purchasely/references/flutter/integration.md @@ -1,6 +1,6 @@ # Flutter Integration -Purchasely Flutter is on the **v6 API**, the same generation as the native iOS and Android SDKs. The plugin pins the **6.0.0-rc.1** Dart packages (`purchasely_flutter`, `purchasely_google`, `purchasely_android_player` are all `6.0.0-rc.1`), which pull the published **6.0.0-rc.2** native SDKs (iOS `Purchasely 6.0.0-rc.2` on the CocoaPods trunk, Android `io.purchasely:core 6.0.0-rc.2` on Maven Central). All public Dart types carry the **`PLY` prefix** (`PLYPresentationBuilder`, `PLYPresentationRequest`, `PLYPresentationOutcome`, `PLYTransition`, …), aligning with the iOS/Android naming convention. The one exception is **SDK initialization**: the builder is started via `Purchasely.apiKey(...)` (a static method on `Purchasely` that returns a `PurchaselyBuilder`). This renaming landed on **2026-06-24** and is a **source-breaking change** for any existing v6 code that used unprefixed names. +Purchasely Flutter is on the **v6 API**, the same generation as the native iOS and Android SDKs, and is **GA / stable** (no longer a pre-release). The plugin pins the **6.0.0** Dart packages (`purchasely_flutter`, `purchasely_google`, `purchasely_android_player` are all `6.0.0`, published on pub.dev), which pull the published **native SDKs** — iOS `Purchasely 6.0.0` on the CocoaPods trunk, Android `io.purchasely:core 6.0.1` on Maven Central. All public Dart types carry the **`PLY` prefix** (`PLYPresentationBuilder`, `PLYPresentationRequest`, `PLYPresentationOutcome`, `PLYTransition`, …), aligning with the iOS/Android naming convention. The one exception is **SDK initialization**: the builder is started via `Purchasely.apiKey(...)` (a static method on `Purchasely` that returns a `PurchaselyBuilder`). Three areas changed shape from v5: **starting the SDK** (`Purchasely.apiKey(...)`), **displaying / preloading / closing a presentation** (`PLYPresentationBuilder` + `PLYPresentationRequest`), and the **action interceptor** (`Purchasely.interceptAction`). Everything else on the `Purchasely` class — purchases, restore, identity, catalog, subscriptions data, user attributes, events, dynamic offerings, consent and config — remains source-compatible. See [`migration-v6.md`](./migration-v6.md) for the full v5 → v6 old→new mapping. @@ -15,33 +15,33 @@ Three areas changed shape from v5: **starting the SDK** (`Purchasely.apiKey(...) > - [`../concepts/user-attributes-targeting.md`](../concepts/user-attributes-targeting.md) — audience targeting + GDPR consent > - [`../concepts/privacy-settings.md`](../concepts/privacy-settings.md) — `revokeDataProcessingConsent` and privacy purposes > - [`../concepts/subscription-checks.md`](../concepts/subscription-checks.md) — gating premium content, restore purchases -> - [`../sdk-versions.md`](../sdk-versions.md) — latest versions (pin Flutter to **6.0.0-rc.1**) +> - [`../sdk-versions.md`](../sdk-versions.md) — latest versions (Flutter is **6.0.0**, stable) ## Installation -Pin all three packages to the exact same version, `6.0.0-rc.1`: +**Requires Dart ≥ 3.0.0.** Pin all three packages to the exact same version, `6.0.0` (now stable, a caret range like `^6.0.0` is fine for reproducible builds, but pin exactly if you prefer to control upgrades manually): ```bash # Core SDK -flutter pub add purchasely_flutter:6.0.0-rc.1 +flutter pub add purchasely_flutter:6.0.0 # Google Play — required if targeting Google Play Store -flutter pub add purchasely_google:6.0.0-rc.1 +flutter pub add purchasely_google:6.0.0 # Video Player — optional, for video support in paywalls on Android -flutter pub add purchasely_android_player:6.0.0-rc.1 +flutter pub add purchasely_android_player:6.0.0 ``` -**CRITICAL: All Purchasely packages must be at the exact same version, pinned exactly (never floating).** Check `pubspec.yaml`: +**CRITICAL: All Purchasely packages must be at the exact same version.** Check `pubspec.yaml`: ```yaml dependencies: - purchasely_flutter: 6.0.0-rc.1 - purchasely_google: 6.0.0-rc.1 - purchasely_android_player: 6.0.0-rc.1 + purchasely_flutter: ^6.0.0 + purchasely_google: ^6.0.0 + purchasely_android_player: ^6.0.0 ``` -> **Native dependency.** `purchasely_flutter 6.0.0-rc.1` pulls the **6.0.0-rc.2** native SDKs transitively — iOS `Purchasely 6.0.0-rc.2` (CocoaPods trunk) and Android `io.purchasely:core 6.0.0-rc.2` (Maven Central). Both are published, so the project builds from the public repositories with no `mavenLocal()` and no development pod. You do not bump the native pods/gradle dependencies yourself; the plugin's pinning is correct. +> **Native dependency.** `purchasely_flutter 6.0.0` pulls the native SDKs transitively — iOS `Purchasely 6.0.0` (CocoaPods trunk) and Android `io.purchasely:core 6.0.1` (Maven Central). Both are stable, published GA releases, so the project builds from the public repositories with no `mavenLocal()` and no development pod. You do not bump the native pods/gradle dependencies yourself; the plugin's pinning is correct. ### iOS Setup @@ -160,6 +160,8 @@ final request = PLYPresentationBuilder.placement('ONBOARDING') .build(); ``` +> **Callbacks are mutable and reassignable.** `onPresented` / `onCloseRequested` / `onDismissed` set on the builder are copied onto the loaded `PLYPresentation` as a **fallback** once `preload()` resolves. You can also set or replace any of them directly on the loaded `PLYPresentation` between `preload()` and `display()` — the last value set before `display()` wins. This lets you build/preload a request early (e.g. at app start) and attach the real callbacks later, once the screen that will display it is actually mounted. + ### Transitions `display([PLYTransition])` accepts an optional `PLYTransition`. Named factory constructors: @@ -192,7 +194,7 @@ const PLYTransition.popin( | `closeReason` | `PLYCloseReason?` | `button` \| `backSystem` \| `programmatic` (when no purchase) | | `error` | `PLYPresentationError?` | Display error; mutually exclusive with `closeReason` | -> **iOS / Android `closeReason` parity.** Both native 6.0 SDKs expose `closeReason` on the outcome, and Flutter surfaces it on both platforms. iOS maps its interactive dismiss (swipe-down / nav-pop) to `backSystem` to stay aligned with Android's `BACK_SYSTEM`. +> **iOS parity gap — `closeReason` and `contentId` are `null` on iOS.** The iOS 6.0 native SDK does not expose `closeReason`, nor `contentId`, for a loaded presentation — only Android does. Flutter surfaces both fields on the outcome/presentation object on every platform, but on iOS they come back `null` because there is nothing for the bridge to forward; this is a native iOS SDK gap, **not a Flutter bridge bug**. Do not build iOS-only logic that assumes either field is populated. > **`PLYPlan` fields.** `outcome.plan` is a fully-typed `PLYPlan?` — the same model returned by `planWithIdentifier`. Access fields directly: `outcome.plan?.vendorId`, `outcome.plan?.name`, `outcome.plan?.amount`. The v6 SDK also exposes offer-price fields: `hasOfferPrice`, `offerPrice`, `offerAmount`, `offerDuration`, `offerPeriod` (the old `intro*` fields remain as deprecated aliases). @@ -227,6 +229,8 @@ class InlinePaywallScreen extends StatelessWidget { } ``` +> **Android: hybrid composition is mandatory for `PLYPresentationView`.** The paywall content is rendered by the native Android view, and a plain virtual-display `AndroidView` does not reliably deliver taps into it (buttons silently no-op or intercept touches meant for Flutter). Enable hybrid composition for this view in your Android embedding configuration — do not fall back to virtual display to "simplify" the integration. + ## Action Interceptor Intercept paywall actions to inject custom behavior. Register **one handler per action kind** with `Purchasely.interceptAction(kind, handler)`. The handler returns a `PLYInterceptResult` that tells the SDK how the action was handled: @@ -298,6 +302,12 @@ Purchasely.userLogin('user_123'); Purchasely.userLogout(); ``` +`userLogout({bool clearUserAttributes = true})` takes an optional parameter (new in v6): pass `clearUserAttributes: false` if you want to keep the custom attributes set on the anonymous/previous user across the logout instead of clearing them. + +```dart +Purchasely.userLogout(clearUserAttributes: false); +``` + ## Programmatic Purchases For app-side purchase buttons in Full mode, use `purchaseWithPlanVendorId` (unchanged). Do not use `Purchasely.purchase(planId: ...)`; that API is not exposed by the Flutter bridge. @@ -357,7 +367,7 @@ for (final sub in subscriptions) { > **`presentSubscriptions()` is REMOVED in v6 (BREAKING).** The native subscriptions screen was removed from the 6.0 SDKs on **both** platforms, so `Purchasely.presentSubscriptions()` has been **removed entirely** from the Flutter API — it is not a no-op, the method no longer exists. There is no drop-in replacement: build your own subscriptions screen from `userSubscriptions()` / `userSubscriptionsHistory()`. > -> The cancellation survey UI was likewise removed, so `Purchasely.displaySubscriptionCancellationInstruction()` is kept for source compatibility but is a **no-op on both Android and iOS**. +> The cancellation survey UI was likewise removed. `Purchasely.displaySubscriptionCancellationInstruction()` is **removed entirely** from the Flutter API on both Android and iOS — it is not kept as a no-op; the method no longer exists, so any remaining call site fails to compile. ## Pre-fetching Screens @@ -421,7 +431,11 @@ presentation.back(); // navigate back inside a multi-step (Flow) presentatio ## Deeplinks -v6 displays deeplinks and campaigns immediately by default. Allow or gate them on the builder, feed a **cold-start** deeplink via the builder's `handleDeeplink(...)`, and feed **runtime** deeplinks via `Purchasely.handleDeeplink(...)`. +v6 displays deeplinks and campaigns immediately by default (native default `true` on every platform). There are **three distinct mechanisms** — do not conflate them: + +1. **`PurchaselyBuilder.allowDeeplink(bool)`** — authorisation gate on the start builder (+ its runtime twin `Purchasely.allowDeeplink(bool)`). Controls whether the SDK is *allowed* to display deeplink/campaign presentations at all. +2. **`PurchaselyBuilder.handleDeeplink(String?)`** — replays the **cold-start** deeplink (the one that launched the app), resolved once `start()` completes. Not a general handler — it only exists to hand the SDK the URL the app was launched with. +3. **`Purchasely.handleDeeplink(String) → Future`** — the **runtime** call for a deeplink received while the app is already running (e.g. from your app's own deeplink/router callback). ### Allow Deeplinks @@ -430,12 +444,16 @@ Deeplink display is allowed via the start builder; `Purchasely.allowDeeplink(boo ```dart await Purchasely.apiKey('YOUR_API_KEY') .allowDeeplink(true) + .allowCampaigns(true) .start(); -// Toggle later at runtime: +// Toggle later at runtime — independent flags: await Purchasely.allowDeeplink(true); +await Purchasely.allowCampaigns(true); ``` +> **`automaticDeeplinkHandling(bool)` — Android-only.** Builder modifier, defaults to `true`. When `true` (default), the Android native SDK auto-intercepts incoming deeplinks without the app forwarding them through `Purchasely.handleDeeplink(...)`. It is a **no-op on iOS** — iOS always requires the app to forward the URL explicitly. + ### Cold-Start Deeplink (deeplink that launched the app) When the app is **launched from** a deeplink, pass the captured URL to the start @@ -463,7 +481,7 @@ if (handled) { } ``` -> **Events on a deeplink open:** `DEEPLINK_OPENED` → `PRESENTATION_LOADED` → `PRESENTATION_VIEWED`. `PRESENTATION_OPENED` is **not** emitted for a deeplink (only for in-paywall action opens). +> **Events on a deeplink open:** `DEEPLINK_OPENED`, `PRESENTATION_LOADED`, and `PRESENTATION_VIEWED` all fire, but **the relative order of `DEEPLINK_OPENED` vs `PRESENTATION_LOADED` differs between iOS and Android** — do not write event-listener logic that assumes one fires strictly before the other across platforms. `PRESENTATION_OPENED` is **not** emitted for a deeplink (only for in-paywall action opens). > **`readyToOpenDeeplink` and `isDeeplinkHandled` were removed in v6.** Use `allowDeeplink` / `handleDeeplink` instead. @@ -480,12 +498,13 @@ await Purchasely.setDefaultPresentationDismissHandler((outcome) { ## Synchronize Purchases -Force synchronization with Purchasely servers. In v6 `synchronize()` returns `Future` — it **resolves with `true` when synchronization completes** and **throws a `PlatformException` on failure** (the v5 fire-and-forget behaviour is gone). `await` it (and optionally `try/catch`) before chaining a follow-up presentation that targets subscribers: +Force synchronization with Purchasely servers. In v6 `synchronize()` returns `Future` — it **resolves `true` when synchronization completes** and **throws a `PlatformException` on failure** (the v5 fire-and-forget behaviour is gone). A resolved value of **`false` means the receipt is still pending store-side validation — it is not a failure** and does not throw; treat it as "not ready yet", not as an error to surface to the user. `await` it (and optionally `try/catch`) before chaining a follow-up presentation that targets subscribers: ```dart try { final ok = await Purchasely.synchronize(); - // ok == true when synchronization completed successfully + // ok == true: synchronization completed. ok == false: receipt still pending + // validation store-side — not a failure, just not resolved yet. } on PlatformException catch (e) { print('Synchronize failed: ${e.message}'); } @@ -494,7 +513,7 @@ try { ## Bridge & version alignment notes - The Dart ↔ native bridge is still **MethodChannel** (`purchasely`) + **EventChannels** (`purchasely-events`, `purchasely-purchases`, `purchasely-user-attributes`). v6 changes the public Dart surface, not the bridge transport. -- **All three `purchasely_*` packages MUST be the exact same version** (`6.0.0-rc.1`). Mixing versions causes runtime crashes. Pin exactly — never floating (`^6.0.0`, `6.+`). +- **All three `purchasely_*` packages MUST be the exact same version** (`6.0.0`). Mixing versions causes runtime crashes. Now that the release is stable, a caret range (`^6.0.0`) applied consistently to all three is fine; pin exactly if you prefer to control upgrades manually. - Run a fresh install after pinning: `flutter clean && flutter pub get`, then `pod install --repo-update` (iOS) and `./gradlew --refresh-dependencies` (Android) as needed. - See [`../sdk-versions.md`](../sdk-versions.md) for the canonical version table and [`./migration-v6.md`](./migration-v6.md) for the full v5 → v6 old→new mapping. diff --git a/purchasely/references/flutter/migration-v6.md b/purchasely/references/flutter/migration-v6.md index cecfbf3..70862cb 100644 --- a/purchasely/references/flutter/migration-v6.md +++ b/purchasely/references/flutter/migration-v6.md @@ -1,14 +1,15 @@ # Flutter — Migrating to the Purchasely 6.0 API -> **Published as a pre-release.** The Flutter v6 API ships in -> `purchasely_flutter: 6.0.0-rc.1` (and the matching `purchasely_google` / -> `purchasely_android_player` packages), live on pub.dev alongside the native -> iOS `Purchasely 6.0.0-rc.2` and Android `io.purchasely:core 6.0.0-rc.2` -> pre-releases. The builder-based API documented below (`Purchasely.apiKey(...)`, -> `PLYPresentationBuilder`, `Purchasely.interceptAction`) is the current published -> surface — the v5 API (`Purchasely.start(...)`, `fetchPresentation` / -> `presentPresentation[ForPlacement]`, `setPaywallActionInterceptorCallback` + -> `onProcessAction`, `closePresentation()`) is gone. +> **GA / stable.** The Flutter v6 API ships in +> `purchasely_flutter: 6.0.0` (and the matching `purchasely_google` / +> `purchasely_android_player` packages, also `6.0.0`), published stable on +> pub.dev alongside the native iOS `Purchasely 6.0.0` and Android +> `io.purchasely:core 6.0.1` GA releases. The builder-based API documented below +> (`Purchasely.apiKey(...)`, `PLYPresentationBuilder`, `Purchasely.interceptAction`) +> is the current published surface — the v5 API (`Purchasely.start(...)`, +> `fetchPresentation` / `presentPresentation[ForPlacement]`, +> `setPaywallActionInterceptorCallback` + `onProcessAction`, `closePresentation()`) +> is gone. > **In-repo migration guide.** This is the Flutter-specific old→new mapping for the > Purchasely 6.0 plugin. The companion integration reference is @@ -16,7 +17,7 @@ > [`../concepts/`](../concepts/). This release **adapts the Purchasely Flutter plugin to the Purchasely 6.0 native -SDKs** (iOS `Purchasely 6.0.0-rc.2`, Android `io.purchasely:core 6.0.0-rc.2`). +SDKs** (iOS `Purchasely 6.0.0`, Android `io.purchasely:core 6.0.1`). Three areas are breaking changes: **starting the SDK**, **displaying / preloading / closing a presentation**, and the **action interceptor**. Everything else on the @@ -392,7 +393,7 @@ await Purchasely.setDefaultPresentationDismissHandler((outcome) { final handled = await Purchasely.handleDeeplink('app://ply/presentations/'); ``` -> **`readyToOpenDeeplink` and `isDeeplinkHandled` were removed in v6.** Use `allowDeeplink` / `handleDeeplink` instead. +> **`readyToOpenDeeplink` and `isDeeplinkHandled` were removed in v6.** Use `allowDeeplink` / `handleDeeplink` instead. Deeplink handling is actually **three distinct mechanisms** (authorisation via `allowDeeplink`, cold-start replay via the builder's `handleDeeplink(String?)`, and the runtime `Purchasely.handleDeeplink(String)`), plus the Android-only `automaticDeeplinkHandling(bool)` (default `true`, no-op on iOS) — see [`integration.md`](./integration.md#deeplinks) for the full breakdown, including the iOS/Android event-order gotcha. --- @@ -425,11 +426,12 @@ name, signature and behaviour: - **Purchases**: `purchaseWithPlanVendorId`, `signPromotionalOffer`. - **Restore**: `restoreAllProducts`, `silentRestoreAllProducts`, `userDidConsumeSubscriptionContent`. -- **Identity**: `userLogin`, `userLogout`, `isAnonymous`, `anonymousUserId`. +- **Identity**: `userLogin`, `userLogout` (new optional `{bool clearUserAttributes = true}` parameter — pass `false` to keep custom attributes across logout), `isAnonymous`, `anonymousUserId`. - **Catalog**: `allProducts`, `productWithIdentifier`, `planWithIdentifier`, `isEligibleForIntroOffer`. -- **Subscriptions data**: `userSubscriptions`, `userSubscriptionsHistory`, - `displaySubscriptionCancellationInstruction` (see callout below). +- **Subscriptions data**: `userSubscriptions`, `userSubscriptionsHistory` + (`displaySubscriptionCancellationInstruction` is **not** in this unchanged + list — see the removal callout below). - **User attributes**: `setUserAttributeWithString` / `WithInt` / `WithDouble` / `WithBoolean` / `WithDate` / `WithStringArray` / `WithIntArray` / `WithDoubleArray` / `WithBooleanArray`, `incrementUserAttribute`, @@ -449,8 +451,17 @@ name, signature and behaviour: > `Purchasely.synchronize()` now returns **`Future`** (was `Future`): > it **resolves with `true` when synchronization actually completes** and > **throws a `PlatformException` on failure**, instead of the previous -> fire-and-forget behaviour. `await` it (and optionally `try/catch`) before -> chaining a follow-up presentation that targets subscribers. +> fire-and-forget behaviour. A resolved **`false` means the receipt is still +> pending store-side validation — it is not a failure**, so don't treat it as +> one. `await` it (and optionally `try/catch`) before chaining a follow-up +> presentation that targets subscribers. +> +> **Do not call `synchronize()` from inside the `purchase`/`restore` action +> interceptor.** Returning a success result from that interceptor in Observer +> mode already auto-synchronizes with Purchasely — see +> [`../concepts/observer-mode-post-purchase.md`](../concepts/observer-mode-post-purchase.md). +> Manual `synchronize()` is only for purchases handled entirely outside the +> interceptor (a custom sale screen, or BYOS). > **Removed `presentSubscriptions()` (BREAKING).** The native subscriptions > screen was removed from the 6.0 SDKs on both platforms. @@ -465,7 +476,7 @@ name, signature and behaviour: > were **removed** in v6. Use `allowDeeplink` / `handleDeeplink` instead. > **Native dependency.** This Flutter release targets the Purchasely v6 native SDKs -> (iOS `Purchasely 6.0.0-rc.2`, Android `io.purchasely:core 6.0.0-rc.2`), published as pre-releases +> (iOS `Purchasely 6.0.0`, Android `io.purchasely:core 6.0.1`), published as stable GA releases > on CocoaPods / Maven Central — see [`../sdk-versions.md`](../sdk-versions.md) for the canonical -> pins. The published **Flutter** package is `purchasely_flutter: 6.0.0-rc.1`, which pulls those +> pins. The published **Flutter** package is `purchasely_flutter: 6.0.0`, which pulls those > native versions transitively. diff --git a/purchasely/references/ios/api-reference.md b/purchasely/references/ios/api-reference.md index a37aa2c..ebacf22 100644 --- a/purchasely/references/ios/api-reference.md +++ b/purchasely/references/ios/api-reference.md @@ -1,6 +1,6 @@ # iOS API Reference -> Documents the **v6.0.0-rc.1** public surface (Swift + Objective-C). Migrating from v5? See [`migration-v6.md`](migration-v6.md) and the legacy [`v5-api-reference.md`](v5-api-reference.md). Universal concepts (running modes, log levels, presentation types) also live in [`../concepts/`](../concepts/README.md). +> Documents the **v6.0.0** (stable GA) public surface (Swift + Objective-C). Migrating from v5? See [`migration-v6.md`](migration-v6.md) and the legacy [`v5-api-reference.md`](v5-api-reference.md). Universal concepts (running modes, log levels, presentation types) also live in [`../concepts/`](../concepts/README.md). ## Initialization — fluent builder @@ -165,12 +165,16 @@ do { |--------------------|------------|--------------| | `fetchCompletion:` | The presentation was fetched | `.preload { presentation, error in … }` | | `loadedCompletion:` | The paywall is on screen | `.onPresented { presentation, error in … }` | +| — | The user requests a close (e.g. taps the X) | `.onCloseRequested { … }` (renamed from `onClose`; no compatibility alias) | | `completion:` | The paywall was dismissed | `.onDismissed { outcome in … }` | +`.onCloseRequested` fires on the close *request*; the final dismissal with the outcome still arrives via `.onDismissed`. This mirrors the Android builder's `onCloseRequested { }` hook. + ```swift PLYPresentationBuilder.from(placementId: "ONBOARDING") .backgroundColor(.systemBackground) // optional color override .onPresented { presentation, error in /* paywall is on screen */ } + .onCloseRequested { /* user tapped the close/back control */ } .onDismissed { outcome in /* user closed; outcome carries the purchase result */ } .build() .display(completion: nil) @@ -270,7 +274,9 @@ Objective-C reads the same fields on `PLYPresentationOutcome *`: `outcome.purcha ### `PLYPresentation` is now a protocol -`PLYPresentation` changed from a class to a public `@objc protocol`. **Reading members and calling methods works unchanged** — every property (`id`, `placementId`, `plans`, `metadata`, `isFlow`, …) and method (`display(from:)`, `close()`, `back()`, …) is a protocol requirement that resolves identically. +`PLYPresentation` changed from a class to a public `@objc protocol`. **Reading members and calling methods works unchanged** — every property (`screenId`, `placementId`, `plans`, `metadata`, `isFlow`, …) and method (`display(from:)`, `close()`, `back()`, …) is a protocol requirement that resolves identically. + +> `.id` was renamed **`.screenId`** (no compatibility alias) and is now **non-optional** (`String`, not `String?`). - **Objective-C** signatures `(PLYPresentation *)` → `(id)`. Method bodies typically need no other edits. - **Swift** may write `any PLYPresentation` (both `PLYPresentation` and `any PLYPresentation` compile). @@ -320,6 +326,8 @@ Purchasely.allowDeeplink(true) // any queued deeplink displays immediately Purchasely.allowCampaigns(false) // independent flag for campaigns ``` +`allowCampaigns(_:)` defaults to **`true`** in v6 (v5 defaulted to `false`). Opening a queued campaign deeplink is also gated on the SDK's configuration being ready — even with `allowCampaigns(true)`, a campaign will not open until `start()` has finished configuring the SDK. + ## Presentation Dismiss Handler ### `Purchasely.setDefaultPresentationDismissHandler(handler:)` @@ -392,6 +400,12 @@ Purchasely.setUserAttributes([ ]) ``` +### Built-in attribute keys — `PLYAttribute` + +`PLYAttribute.oneSignalPlayerId` is **removed** (no alias) — use `.oneSignalExternalId` or `.oneSignalUserId` instead, matching OneSignal's current SDK identifiers. + +> **Gotcha — backend key rename.** The corresponding backend attribute key also changed, from `onesignal_player_id` to `onesignal_external_id`. Any audience targeting rule still pinned to the old `onesignal_player_id` key **stops receiving data silently** (no error) once the app updates to the new attribute — audit and update audience rules alongside the SDK bump. + ## Subscriptions ### `Purchasely.userSubscriptions(success:failure:)` @@ -411,6 +425,8 @@ Purchasely.userSubscriptions( ) ``` +> **Built-in subscriptions UI removed.** `Purchasely.showController(_:type:from:)`, `PLYUIControllerType`, and the legacy "My Subscriptions" screen are **removed** in v6, along with the `PLYEvent` cases `.subscriptionsListViewed` and `.cancellationReasonPublished`. There is no drop-in replacement — build your own subscription management screen from `userSubscriptions()` / `userSubscriptionsHistory()`. (There is no iOS API named `presentSubscriptions()` — that name never existed on iOS; `showController` was the v5 entry point for the built-in screen.) + ## Programmatic Purchases Use this for app-side purchase buttons in Full mode. Fetch a `PLYPlan` first; there is no `purchase(planId:)` API. Unchanged from v5. @@ -528,6 +544,9 @@ Purchasely.setUserAttributeDelegate(MyAttributeDelegate()) | `PLYProductViewControllerResult` | `PLYPresentationOutcome *` (struct with `purchaseResult`, `plan`, `presentation`, `closeReason`, `error`) | | `[Purchasely closeDisplayedPresentation]` | `[Purchasely closeAllScreens]` | | `displayMode:` parameter | `transition:` parameter | +| `PLYDisplayMode` (type) | `PLYTransition` (type) | + +> `PLYProductViewControllerResult` and its companion `PLYProductViewControllerCompletionBlock` are not deleted outright — they are **internalized** (kept for the SDK's own implementation, no longer part of the public API surface). App code must use `PLYPresentationOutcome`. ## `PLYPresentationAction` Enum @@ -553,3 +572,7 @@ Type read from a loaded presentation: | `.fallback` | Fallback presentation (network issue, original not found) | | `.deactivated` | Presentation has been deactivated in the dashboard — do not display | | `.client` | Client-side presentation (render your own paywall with Purchasely data) | + +## Monthly Commitment (Apple) + +For Apple's "Monthly with 12-Month Commitment" billing plans (iOS 26.4+, StoreKit 2), the SDK exposes `PLYBillingPlanType`, `PLYCommitmentInfo`, and `PLYCommitmentProgress`. See [`../concepts/monthly-commitment.md`](../concepts/monthly-commitment.md) for the full plan-type and progress-tracking reference. diff --git a/purchasely/references/ios/common-patterns.md b/purchasely/references/ios/common-patterns.md index b72bf92..e6a9865 100644 --- a/purchasely/references/ios/common-patterns.md +++ b/purchasely/references/ios/common-patterns.md @@ -1,6 +1,6 @@ # iOS Common Integration Patterns -> **Platform-specific elaborations for v6.0.0-rc.1.** This file covers iOS idioms (SwiftUI, UIKit, Swift 6 concurrency, external billing / StoreKit 2 bridging). Concepts that apply to **every** Purchasely SDK (Observer-mode post-purchase flow, presentation type guard, presentation cache, audience-targeting attributes, GDPR consent, subscription checks) live in `../concepts/`: +> **Platform-specific elaborations for v6.0.0 (stable GA).** This file covers iOS idioms (SwiftUI, UIKit, Swift 6 concurrency, external billing / StoreKit 2 bridging). Concepts that apply to **every** Purchasely SDK (Observer-mode post-purchase flow, presentation type guard, presentation cache, audience-targeting attributes, GDPR consent, subscription checks) live in `../concepts/`: > > - [`../concepts/running-modes.md`](../concepts/running-modes.md), [`../concepts/paywall-actions.md`](../concepts/paywall-actions.md), [`../concepts/presentation-types.md`](../concepts/presentation-types.md), [`../concepts/presentation-cache.md`](../concepts/presentation-cache.md), [`../concepts/observer-mode-post-purchase.md`](../concepts/observer-mode-post-purchase.md), [`../concepts/user-attributes-targeting.md`](../concepts/user-attributes-targeting.md), [`../concepts/subscription-checks.md`](../concepts/subscription-checks.md). Migrating from v5? See [`migration-v6.md`](migration-v6.md). @@ -148,21 +148,18 @@ Purchasely.interceptAction(.purchase) { info, params in return .notHandled } let error = await ExistingPurchaseManager.shared.purchase(productId: productId) - if error == nil { - Purchasely.synchronize(success: {}, failure: { _ in }) // notify Purchasely for analytics + receipt validation - return .success // app handled the purchase - } - return .failed + return error == nil ? .success : .failed // returning .success auto-synchronizes the receipt } Purchasely.interceptAction(.restore) { info, params in let error = await ExistingPurchaseManager.shared.restorePurchases() - Purchasely.synchronize(success: {}, failure: { _ in }) - return error == nil ? .success : .failed + return error == nil ? .success : .failed // returning .success auto-synchronizes } ``` > 📘 `.notHandled` for `.purchase` / `.restore` in Observer mode logs a warning and skips — the SDK cannot execute purchases in Observer mode. Always return `.success` / `.failed` from your own flow. +> +> 📘 Returning `.success` for `.purchase` / `.restore` **auto-synchronizes** the receipt — do not call `Purchasely.synchronize()` from inside the interceptor. Call it manually only for purchases your app processes **outside** the interceptor flow (e.g. a "Restore Purchases" button on a settings screen, or a BYOS `.client` presentation). ## Observer Mode with StoreKit 2 @@ -184,10 +181,9 @@ Purchasely.interceptAction(.purchase) { info, params in let result = try await product.purchase() switch result { case .success: - Purchasely.synchronize(success: {}, failure: { _ in }) - return .success + return .success // returning .success auto-synchronizes the receipt case .pending, .userCancelled: - return .notHandled // not an error — user backed out + return .notHandled // not an error — user backed out @unknown default: return .failed } @@ -199,8 +195,7 @@ Purchasely.interceptAction(.purchase) { info, params in Purchasely.interceptAction(.restore) { info, params in do { try await AppStore.sync() - Purchasely.synchronize(success: {}, failure: { _ in }) - return .success + return .success // returning .success auto-synchronizes } catch { return .failed } @@ -211,9 +206,8 @@ Purchasely.interceptAction(.restore) { info, params in After a successful Observer-mode purchase, the recommended sequence is: -1. **Await `synchronize()`** (only if you chain a follow-up placement that targets users based on subscription state — otherwise fire-and-forget is fine) -2. **Return `.success`** from the interceptor — tells the SDK the action was handled -3. **`Purchasely.closeAllScreens()`** — force-dismiss the paywall +1. **Return `.success`** from the interceptor — tells the SDK the action was handled, and **auto-synchronizes** the receipt. Do not call `Purchasely.synchronize()` yourself here. +2. **`Purchasely.closeAllScreens()`** — force-dismiss the paywall The order **return result → closeAllScreens** matters: the interceptor must learn the action was handled before the paywall tears down. @@ -224,39 +218,30 @@ func handlePurchase(params: PLYPresentationActionParameters?) async -> PLYInterc let result = await PurchaseManager.shared.purchase(productId: productId) switch result { case .success: - try? await synchronizeReceipt() // only await if you chain a placement that targets subscribers Purchasely.closeAllScreens() // dismiss after returning .success - return .success + return .success // auto-synchronizes the receipt case .cancelled: return .notHandled // user backed out case .error: return .failed } } - -private func synchronizeReceipt() async throws { - try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in - Purchasely.synchronize( - success: { cont.resume() }, - failure: { error in - cont.resume(throwing: error ?? NSError(domain: "Purchasely", code: -1)) - } - ) - } -} ``` > `closeAllScreens()` is `@MainActor`-isolated. From a non-isolated context, wrap in `Task { @MainActor in Purchasely.closeAllScreens() }`. It replaces the removed `closeDisplayedPresentation()`. +> +> `Purchasely.synchronize()` remains useful for transactions your app processes **outside** the interceptor flow — e.g. a "Restore Purchases" button on a settings screen, or a BYOS `.client` presentation with its own purchase button. If a follow-up placement needs guaranteed fresh subscription state and you can't rely on the automatic sync's timing, call and await `synchronize()` yourself before fetching that placement (see below). ### Chaining a Follow-up Placement After Purchase (optional) Some apps display a follow-up paywall after a successful purchase — a thank-you screen, a premium onboarding tour, a one-tap upsell. This is **not part of the SDK contract**: it's just `PLYPresentationBuilder` called again with whatever placement ID you've configured on the Console (e.g. `"post_purchase"`, `"thank_you"` — pick your own). -If you chain a placement whose audience targets users by subscription state, **`synchronize()` must complete first** — otherwise the fetch resolves against stale state and may return a deactivated/fallback presentation. +If you chain a placement whose audience targets users by subscription state, **`synchronize()` must complete first** — otherwise the fetch resolves against stale state and may return a deactivated/fallback presentation. The interceptor's own `.success` return already auto-synchronizes, but that happens with no completion signal your app can observe — so here, called from *outside* the interceptor, awaiting your own `synchronize()` is the right call: ```swift @MainActor -private func showPostPurchaseScreen() { +private func showPostPurchaseScreen() async { + try? await synchronizeReceipt() // wait for the auto-sync to have definitely landed PLYPresentationBuilder .forPlacementId("YOUR_POST_PURCHASE_PLACEMENT_ID") .onDismissed { _ in /* dismissed */ } @@ -269,6 +254,17 @@ private func showPostPurchaseScreen() { presentation.display(from: topVC) } } + +private func synchronizeReceipt() async throws { + try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in + Purchasely.synchronize( + success: { cont.resume() }, + failure: { error in + cont.resume(throwing: error ?? NSError(domain: "Purchasely", code: -1)) + } + ) + } +} ``` **Naming gotcha:** the placement ID string must match the Console exactly — typos silently return a deactivated presentation. diff --git a/purchasely/references/ios/initialization.md b/purchasely/references/ios/initialization.md index 4a856cb..f0f928b 100644 --- a/purchasely/references/ios/initialization.md +++ b/purchasely/references/ios/initialization.md @@ -1,45 +1,47 @@ # iOS SDK Initialization -> Documents the **v6.0.0-rc.1** fluent initialization builder. Migrating from v5? See [`migration-v6.md`](migration-v6.md). Universal concepts (running modes, log levels, etc.) also live in [`../concepts/`](../concepts/README.md). +> Documents the **v6.0.0** (stable GA) fluent initialization builder. Migrating from v5? See [`migration-v6.md`](migration-v6.md). Universal concepts (running modes, log levels, etc.) also live in [`../concepts/`](../concepts/README.md). ## Installation -### CocoaPods +### Swift Package Manager (primary) -Add to your `Podfile`: +Add the package URL in Xcode (File ▸ Add Packages ▸ enter the URL), selecting **Up to Next Major Version** `from: "6.0.0"`: -```ruby -pod 'Purchasely', '6.0.0-rc.1' +``` +https://github.com/Purchasely/Purchasely-iOS ``` -Then run: +Or add to your `Package.swift`: -```bash -pod install +```swift +dependencies: [ + .package(url: "https://github.com/Purchasely/Purchasely-iOS", from: "6.0.0") +] ``` -### Swift Package Manager +### CocoaPods -Add the package URL in Xcode (File ▸ Add Packages ▸ enter the URL): +Add to your `Podfile`: -``` -https://github.com/Purchasely/Purchasely-iOS +```ruby +pod 'Purchasely', '~> 6.0' ``` -Or add to your `Package.swift`: +Then run: -```swift -dependencies: [ - .package(url: "https://github.com/Purchasely/Purchasely-iOS", exact: "6.0.0-rc.1") -] +```bash +pod install ``` +CocoaPods and binary distribution are published from the `Purchasely/Purchasely-iOS` repo — the SDK's own dev repo is now SPM-only. + ### Carthage Add to your `Cartfile`: ``` -binary "https://raw.githubusercontent.com/Purchasely/Purchasely-iOS/master/Purchasely.json" == 6.0.0-rc.1 +binary "https://raw.githubusercontent.com/Purchasely/Purchasely-iOS/master/Purchasely.json" ~> 6.0 ``` Then run: @@ -48,8 +50,6 @@ Then run: carthage update ``` -Pin **exactly** (`== 6.0.0-rc.1`) — a floating constraint will not resolve a pre-release. - ## Import ```swift @@ -146,7 +146,7 @@ struct MyApp: App { | `environment(_:)` | `.prod` | | | `themeMode(_:)` | `.system` | | | `allowDeeplink(_:)` | `true` | Deeplinks display immediately; pass `false` to defer until `Purchasely.allowDeeplink(true)` | -| `allowCampaigns(_:)` | `true` | Campaigns display immediately; pass `false` to defer until `Purchasely.allowCampaigns(true)` | +| `allowCampaigns(_:)` | `true` ⚠️ | **Was `false` in v5.** Campaigns display immediately; pass `false` to defer until `Purchasely.allowCampaigns(true)`. Opening a queued campaign is additionally gated on the SDK's configuration being ready. | | `handleDeeplink(_:)` | unset | Pass a cold-start deeplink to display once the SDK has started | > 📘 The pre-`start` class funcs `setEnvironment(_:)`, `setShowPromotedInAppPurchasePaywall(_:)`, `setAppTechnology(_:)`, `setSdkBridgeVersion(_:)`, `setThemeMode(_:)` are **deprecated** (removal in v7). Use the chain modifiers above instead. diff --git a/purchasely/references/ios/migration-v6.md b/purchasely/references/ios/migration-v6.md index cea3529..9918400 100644 --- a/purchasely/references/ios/migration-v6.md +++ b/purchasely/references/ios/migration-v6.md @@ -1,8 +1,8 @@ -# iOS SDK v5.x → v6.0.0-rc.1 Migration +# iOS SDK v5.x → v6.0.0 Migration This guide is iOS-only (Swift / SwiftUI / UIKit). Android, React Native, Flutter, and Cordova have their own migration notes — do not apply this one to them. -Version 6.0.0-rc.1 introduces a fluent initialization builder, a granular per-action interceptor API, clearer naming, and a consolidated paywall display surface built around `PLYPresentationBuilder`. `PLYPresentation` becomes a protocol (most call sites compile unchanged). For the full v6 API surface see [`api-reference.md`](api-reference.md); for the legacy symbols this guide replaces see [`v5-api-reference.md`](v5-api-reference.md). +Version 6.0.0 (stable GA) introduces a fluent initialization builder, a granular per-action interceptor API, clearer naming, and a consolidated paywall display surface built around `PLYPresentationBuilder`. `PLYPresentation` becomes a protocol (most call sites compile unchanged). For the full v6 API surface see [`api-reference.md`](api-reference.md); for the legacy symbols this guide replaces see [`v5-api-reference.md`](v5-api-reference.md). ## Summary of breaking changes @@ -14,31 +14,38 @@ Version 6.0.0-rc.1 introduces a fluent initialization builder, a granular per-ac | `PLYPresentationInfo` | `PLYInterceptorInfo` | | `Purchasely.fetchPresentation(...)` | `PLYPresentationBuilder.…build().preload { … }` | | `Purchasely.display(for:displayMode:)` | `Purchasely.display(for:transition:)` | +| `PLYDisplayMode` (type) | `PLYTransition` (type) | | `Purchasely.closeDisplayedPresentation()` | `Purchasely.closeAllScreens()` | | `controller.PresentationView` | `presentation.swiftUIView` (SwiftUI) / `presentation.controller` (UIKit) | -| `Purchasely.productView(…)` / `planView(…)` / `presentationView(…)` | `PLYPresentationBuilder.…build().preload { … }` → `presentation.swiftUIView` | +| `Purchasely.productView(…)` / `planView(…)` / `presentationView(…)` (8 factories) | `PLYPresentationBuilder.…build().preload { … }` → `presentation.swiftUIView` | +| Builder `onClose` callback | `.onCloseRequested { … }` | +| `presentation.id` | `presentation.screenId` (now non-optional) | | `readyToOpenDeeplink(_:)` | `allowDeeplink(_:)` | | `isDeeplinkHandled(deeplink:)` | `handleDeeplink(_:)` | | `ply/products/*` / `ply/plans/*` deeplinks | `ply/presentations/` / `ply/placements/` | -| `PLYProductViewControllerResult` | `PLYPresentationOutcome` | +| `PLYProductViewControllerResult` | `PLYPresentationOutcome` (the old type is internalized, not public) | +| `PLYAttribute.oneSignalPlayerId` | `.oneSignalExternalId` / `.oneSignalUserId` | +| `Purchasely.showController(_:type:from:)` / `PLYUIControllerType` / legacy "My Subscriptions" screen | removed — build your own from `userSubscriptions()` / `userSubscriptionsHistory()` | | Objective-C `PLYPresentation *` | `id` | ## Dependency -Bump the Purchasely iOS package to `6.0.0-rc.1`. +Bump the Purchasely iOS package to `6.0.0` (stable GA). -**Swift Package Manager** — in `Package.swift` or the Xcode package list: +**Swift Package Manager** (primary) — in `Package.swift` or the Xcode package list: ```swift -.package(url: "https://github.com/Purchasely/Purchasely-iOS", exact: "6.0.0-rc.1") +.package(url: "https://github.com/Purchasely/Purchasely-iOS", from: "6.0.0") ``` **CocoaPods** — in the `Podfile`: ```ruby -pod 'Purchasely', '6.0.0-rc.1' +pod 'Purchasely', '~> 6.0' ``` +CocoaPods and binary distribution are published from the `Purchasely/Purchasely-iOS` repo; the SDK's own dev repo is SPM-only. + After bumping, resolve packages (`File ▸ Packages ▸ Resolve Package Versions`, or `pod install`) and clean the build folder before the first compile. ### Swift 6 strict concurrency @@ -206,13 +213,15 @@ func handlePurchase(params: PLYPresentationActionParameters?) async -> PLYInterc guard let productId = params?.plan?.appleProductId else { return .notHandled } let result = await PurchaseManager.shared.purchase(productId: productId) switch result { - case .success: try? await synchronizeReceipt(); return .success + case .success: return .success // returning .success auto-synchronizes the receipt case .cancelled: return .notHandled // user backed out — not an error case .error: return .failed } } ``` +> Returning `.success` for `.purchase` / `.restore` in Observer mode **auto-synchronizes** — do not call `Purchasely.synchronize()` from inside the interceptor. Call it manually only for transactions your app processes **outside** the interceptor flow (a "Restore Purchases" button on a settings screen, a BYOS `.client` presentation). + Return `.notHandled` in Full mode so the SDK runs its own purchase/restore flow. ## 3. Presentation API — `PLYPresentationBuilder` @@ -312,7 +321,9 @@ The v5 dismissal tuple `(PLYProductViewControllerResult, PLYPlan?)` becomes a si ## 4. `PLYPresentation` is now a protocol -`PLYPresentation` changed from a class to a public `@objc protocol`. **Reading members and calling methods works unchanged** — every property (`id`, `placementId`, `plans`, `metadata`, `isFlow`, …) and method (`display(from:)`, `close()`, `back()`, …) resolves identically. Swift may write `any PLYPresentation`. +`PLYPresentation` changed from a class to a public `@objc protocol`. **Reading members and calling methods works unchanged** — every property (`screenId`, `placementId`, `plans`, `metadata`, `isFlow`, …) and method (`display(from:)`, `close()`, `back()`, …) resolves identically. Swift may write `any PLYPresentation`. + +> `.id` was renamed **`.screenId`** (no compatibility alias) and is now **non-optional** (`String`, not `String?`). ## 5. SwiftUI — `swiftUIView` (UIKit keeps `controller`) @@ -365,6 +376,8 @@ let handled = Purchasely.handleDeeplink(url) In v6, deeplinks display **immediately** by default. Call `Purchasely.allowDeeplink(false)` to defer (e.g. during onboarding) and `allowDeeplink(true)` when ready. Hand a cold-start deeplink at init: `Purchasely.apiKey("…").handleDeeplink(url).start { error in }`. Unlike Android, iOS does **not** auto-intercept — keep passing deeplinks via `Purchasely.handleDeeplink(_:)` from your `AppDelegate` / `SceneDelegate`. +`allowCampaigns(_:)` also **defaults to `true` in v6** (v5 defaulted to `false`). Opening a queued campaign deeplink is additionally gated on the SDK's configuration being ready — even with `allowCampaigns(true)`, a campaign will not display until `start()` has finished. + ### Product / plan deeplinks removed (breaking) The `ply/products/*` and `ply/plans/*` deeplink formats are **removed** in v6, along with the internal `productController` factory that served them. A deeplink to one of these paths is no longer handled — deep-link to a placement or a presentation instead (configure the target screen in the Console): @@ -400,6 +413,16 @@ PLYPresentationBuilder *builder = [PLYPresentationBuilder forPlacementId:@"ONBOA }]; ``` +## 9. Subscriptions UI removed + +`Purchasely.showController(_:type:from:)` (the v5 entry point for the built-in "My Subscriptions" screen) and `PLYUIControllerType` are **removed**, along with the legacy screen itself and the `PLYEvent` cases `.subscriptionsListViewed` / `.cancellationReasonPublished`. There is no drop-in replacement — build your own subscription management UI from `Purchasely.userSubscriptions(success:failure:)` / `Purchasely.userSubscriptionsHistory(success:failure:)`. + +> There has never been a `presentSubscriptions()` method on iOS — if you see that name in iOS code or docs, it is a mix-up with Android/React Native/Cordova (which do have that name). The iOS v5 entry point was `showController`. + +## 10. OneSignal attribute renamed + +`PLYAttribute.oneSignalPlayerId` is **removed** (no alias) — use `.oneSignalExternalId` or `.oneSignalUserId`. The backend attribute key also changed, from `onesignal_player_id` to `onesignal_external_id`; any audience rule still targeting the old key stops receiving data **silently** once the app updates. + ## Unchanged APIs (no migration needed) These v5 signatures are identical in v6 — leave them alone: @@ -427,6 +450,11 @@ These v5 signatures are identical in v6 — leave them alone: - [ ] Update `Purchasely.display(for:displayMode:)` to `Purchasely.display(for:transition:)` - [ ] Replace the `(PLYProductViewControllerResult, PLYPlan?)` tuple with `PLYPresentationOutcome` (`purchaseResult` / `plan` / `closeReason`) - [ ] In Objective-C, change `PLYPresentation *` to `id` and `PLYRunningModePaywallObserver` to `PLYRunningModeObserver` +- [ ] Replace `presentation.id` with `presentation.screenId` (now non-optional) +- [ ] Replace any builder `onClose` callback with `.onCloseRequested { … }` +- [ ] Replace `PLYAttribute.oneSignalPlayerId` with `.oneSignalExternalId` / `.oneSignalUserId`, and audit any audience rule keyed on `onesignal_player_id` +- [ ] Remove `Purchasely.showController(_:type:from:)` / `PLYUIControllerType` calls; build your own subscription screen from `userSubscriptions()` / `userSubscriptionsHistory()` +- [ ] Stop calling `Purchasely.synchronize()` from inside `.purchase` / `.restore` interceptors — returning `.success` auto-synchronizes; keep manual `synchronize()` only for out-of-interceptor transactions ### Deprecated (fix before v7) @@ -440,7 +468,7 @@ These v5 signatures are identical in v6 — leave them alone: Search must return no v5-only API usages in app source/tests: ```bash -rg "paywallObserver|readyToOpenDeeplink|isDeeplinkHandled|setPaywallActionsInterceptor|fetchPresentation|presentationController|productController|planController|PresentationView|productView|planView|presentationView|PLYProductViewControllerResult|PLYPresentationInfo|closeDisplayedPresentation|start\(withAPIKey|ply/products|ply/plans" Sources +rg "paywallObserver|readyToOpenDeeplink|isDeeplinkHandled|setPaywallActionsInterceptor|fetchPresentation|presentationController|productController|planController|PresentationView|productView|planView|presentationView|PLYProductViewControllerResult|PLYPresentationInfo|closeDisplayedPresentation|start\(withAPIKey|ply/products|ply/plans|showController|PLYUIControllerType|oneSignalPlayerId|PLYDisplayMode" Sources ``` > An app may keep a wrapper method *named* `isDeeplinkHandled` that internally calls `Purchasely.handleDeeplink` — that is fine; only the `Purchasely.isDeeplinkHandled(...)` SDK call must be gone. diff --git a/purchasely/references/ios/v5-api-reference.md b/purchasely/references/ios/v5-api-reference.md index a997ec5..4574ea0 100644 --- a/purchasely/references/ios/v5-api-reference.md +++ b/purchasely/references/ios/v5-api-reference.md @@ -1,6 +1,6 @@ # iOS SDK v5.x API — reference for MIGRATION ONLY (replaced in v6) -> **Do not write new v5 code.** This is a compact snapshot of the legacy v5.x public API so the `purchasely-migrate` skill can **recognize** existing v5 code in a project and map it forward. Every symbol below is **removed or deprecated in v6.0.0-rc.1**. For the v6 surface, see [`api-reference.md`](api-reference.md); for the step-by-step migration, see [`migration-v6.md`](migration-v6.md). +> **Do not write new v5 code.** This is a compact snapshot of the legacy v5.x public API so the `purchasely-migrate` skill can **recognize** existing v5 code in a project and map it forward. Every symbol below is **removed or deprecated in v6.0.0** (stable GA). For the v6 surface, see [`api-reference.md`](api-reference.md); for the step-by-step migration, see [`migration-v6.md`](migration-v6.md). ## How to recognize a v5 iOS integration @@ -14,7 +14,8 @@ productView planView presentationView ply/products ply/plans PLYProductViewControllerResult readyToOpenDeeplink isDeeplinkHandled closeDisplayedPresentation displayMode: -PLYPaywallActionsInterceptor +PLYPaywallActionsInterceptor PLYDisplayMode +showController PLYUIControllerType oneSignalPlayerId ``` > `PLYPresentationActionParameters` is **not** a v5-only token on iOS: v6 still passes it to each interceptor as `params`. Only the `PLYPaywallActionsInterceptor` typealias and the `paywallActionsInterceptor:` start parameter were removed. @@ -161,6 +162,18 @@ let handled = Purchasely.isDeeplinkHandled(deeplink: url) → **v6 equivalent:** `Purchasely.handleDeeplink(_:)` (still returns `Bool`). Cold-start variant: `Purchasely.apiKey("…").handleDeeplink(url).start { error in }`. +### `Purchasely.showController(_:type:from:)` / `PLYUIControllerType` (built-in subscriptions UI) — **removed** + +```swift +Purchasely.showController(.subscriptions, type: .subscriptions, from: self) +``` + +→ **v6 equivalent:** removed, no replacement. The legacy "My Subscriptions" screen and the `PLYEvent` cases `.subscriptionsListViewed` / `.cancellationReasonPublished` are removed too. Build your own subscription screen from `userSubscriptions(success:failure:)` / `userSubscriptionsHistory(success:failure:)`. (Note: `showController` — not `presentSubscriptions()` — was the real iOS v5 entry point; `presentSubscriptions()` never existed on iOS.) + +### `PLYAttribute.oneSignalPlayerId` — **removed** + +→ **v6 equivalent:** `.oneSignalExternalId` / `.oneSignalUserId`. The backend attribute key also changed (`onesignal_player_id` → `onesignal_external_id`) — audit audience rules keyed on the old value. + ### `ply/products/*` and `ply/plans/*` deeplink formats — **removed** ``` diff --git a/purchasely/references/react-native/integration.md b/purchasely/references/react-native/integration.md index ce72021..fb79ab2 100644 --- a/purchasely/references/react-native/integration.md +++ b/purchasely/references/react-native/integration.md @@ -1,6 +1,6 @@ # React Native Integration -Purchasely React Native is on the **v6 API**, the same generation as the native iOS and Android SDKs. The plugin pins the **6.0.0-rc.2** pre-release on every layer: all five npm packages (`react-native-purchasely`, `@purchasely/react-native-purchasely-google`, `@purchasely/react-native-purchasely-android-player`, `@purchasely/react-native-purchasely-amazon`, `@purchasely/react-native-purchasely-huawei`) are `6.0.0-rc.2`, and they pull the published native SDKs (iOS `Purchasely 6.0.0-rc.2` on the CocoaPods trunk, Android `io.purchasely:core 6.0.0-rc.2` on Maven Central). The public JS/TS symbols are **`PLY`-prefixed** (`Purchasely.builder`, `PLYPresentationBuilder`, `PLYPresentationRequest`, `PLYLoadedPresentation`, `PLYPresentationOutcome`, `PLYTransition`, …) — there are no `v6` / `V6` symbols. +Purchasely React Native is on the **v6 API**, the same generation as the native iOS and Android SDKs. The plugin pins the **6.0.0-rc.3** pre-release (npm dist-tag `latest`; GA `6.0.0` is in preparation) on every layer: all five npm packages (`react-native-purchasely`, `@purchasely/react-native-purchasely-google`, `@purchasely/react-native-purchasely-android-player`, `@purchasely/react-native-purchasely-amazon`, `@purchasely/react-native-purchasely-huawei`) are `6.0.0-rc.3`, and they pull the published native SDKs (iOS `Purchasely 6.0.0-rc.3` on the CocoaPods trunk, Android `io.purchasely:core 6.0.0-rc.3` on Maven Central — both confirmed pinned in the published `6.0.0-rc.3` tag). The public JS/TS symbols are **`PLY`-prefixed** (`Purchasely.builder`, `PLYPresentationBuilder`, `PLYPresentationRequest`, `PLYLoadedPresentation`, `PLYPresentationOutcome`, `PLYTransition`, …) — there are no `v6` / `V6` symbols. Three areas changed shape from v5: **starting the SDK** (`Purchasely.builder(apiKey)`), **displaying / preloading / closing a presentation** (`Purchasely.presentation` + `PLYPresentationRequest`), and the **action interceptor** (`Purchasely.interceptAction`). Everything else on the `Purchasely` default export — purchases, restore, identity, catalog, subscriptions data, user attributes, events, dynamic offerings, consent and config — remains source-compatible. Note the **deeplink API changed**: `isDeeplinkHandled` / `readyToOpenDeeplink` are **removed** (no alias) — use `Purchasely.handleDeeplink(uri)` and `Purchasely.allowDeeplink(bool)`. See [`migration-v6.md`](./migration-v6.md) for the full v5 → v6 old→new mapping. @@ -15,27 +15,27 @@ Three areas changed shape from v5: **starting the SDK** (`Purchasely.builder(api > - [`../concepts/user-attributes-targeting.md`](../concepts/user-attributes-targeting.md) — audience targeting + GDPR consent > - [`../concepts/privacy-settings.md`](../concepts/privacy-settings.md) — `revokeDataProcessingConsent` and privacy purposes > - [`../concepts/subscription-checks.md`](../concepts/subscription-checks.md) — gating premium content, restore purchases -> - [`../sdk-versions.md`](../sdk-versions.md) — latest versions (pin React Native to **6.0.0-rc.2**) +> - [`../sdk-versions.md`](../sdk-versions.md) — latest versions (pin React Native to **6.0.0-rc.3**) ## Installation -Pin all packages to the exact same version, `6.0.0-rc.2`. Use `--save-exact` — `6.0.0-rc.2` is a pre-release, so a floating range (`^6.0.0`, `6.x`) will not resolve it. +Pin all packages to the exact same version, `6.0.0-rc.3`. Use `--save-exact` — `6.0.0-rc.3` is a pre-release, so a floating range (`^6.0.0`, `6.x`) will not resolve it. ```bash # Core SDK -npm install react-native-purchasely@6.0.0-rc.2 --save-exact +npm install react-native-purchasely@6.0.0-rc.3 --save-exact # Google Play — required if targeting Google Play Store -npm install @purchasely/react-native-purchasely-google@6.0.0-rc.2 --save-exact +npm install @purchasely/react-native-purchasely-google@6.0.0-rc.3 --save-exact # Video Player — optional, for video support in paywalls on Android -npm install @purchasely/react-native-purchasely-android-player@6.0.0-rc.2 --save-exact +npm install @purchasely/react-native-purchasely-android-player@6.0.0-rc.3 --save-exact # Amazon Appstore — optional, Android alt store -npm install @purchasely/react-native-purchasely-amazon@6.0.0-rc.2 --save-exact +npm install @purchasely/react-native-purchasely-amazon@6.0.0-rc.3 --save-exact # Huawei AppGallery — optional, Android alt store -npm install @purchasely/react-native-purchasely-huawei@6.0.0-rc.2 --save-exact +npm install @purchasely/react-native-purchasely-huawei@6.0.0-rc.3 --save-exact ``` **CRITICAL: All Purchasely packages must be at the exact same version, pinned exactly (never floating).** Check `package.json`: @@ -43,16 +43,16 @@ npm install @purchasely/react-native-purchasely-huawei@6.0.0-rc.2 --save-exact ```json { "dependencies": { - "react-native-purchasely": "6.0.0-rc.2", - "@purchasely/react-native-purchasely-google": "6.0.0-rc.2", - "@purchasely/react-native-purchasely-android-player": "6.0.0-rc.2" + "react-native-purchasely": "6.0.0-rc.3", + "@purchasely/react-native-purchasely-google": "6.0.0-rc.3", + "@purchasely/react-native-purchasely-android-player": "6.0.0-rc.3" } } ``` > **Toolchain.** The v6 React Native SDK is built and tested against **React Native 0.86** and **Node 22** (`.nvmrc` → `v22`). -> **Native dependency.** `react-native-purchasely 6.0.0-rc.2` pulls the **6.0.0-rc.2** native SDKs transitively — iOS `Purchasely 6.0.0-rc.2` (CocoaPods trunk) and Android `io.purchasely:core 6.0.0-rc.2` (Maven Central). Both are published, so the project builds from the public repositories. You do not bump the native pods/gradle dependencies yourself; the plugin's pinning is correct. +> **Native dependency.** `react-native-purchasely 6.0.0-rc.3` pulls the **6.0.0-rc.3** native SDKs transitively — iOS `Purchasely 6.0.0-rc.3` (CocoaPods trunk) and Android `io.purchasely:core 6.0.0-rc.3` (Maven Central). Both are published, so the project builds from the public repositories. You do not bump the native pods/gradle dependencies yourself; the plugin's pinning is correct. ### iOS Setup @@ -478,6 +478,8 @@ console.log(request.requestId); // string | null — used to correlate the embed v6 displays deeplinks and campaigns immediately by default. Allow or gate them on the builder with `allowDeeplink` (or the standalone `Purchasely.allowDeeplink(bool)`), and feed runtime deeplinks with **`Purchasely.handleDeeplink(uri)`**. +> **`.allowDeeplink()` is an optional chain modifier, not a required one.** If you omit it from the builder chain, the flag is simply never sent to the native side, and the **native default (`true`)** applies — the same as iOS, Android, and Flutter. There is no RN-specific default of `false`; only call `.allowDeeplink(false)` if you actually want to defer deeplink display (e.g. during onboarding). + > **API change from v5.** `Purchasely.isDeeplinkHandled(uri)` and `Purchasely.readyToOpenDeeplink(bool)` are **removed** in React Native v6 — there is **no alias**. Use `Purchasely.handleDeeplink(uri)` (returns `Promise`) and `.allowDeeplink(true)` on the builder (or `Purchasely.allowDeeplink(true)`). ### Allow Deeplinks @@ -541,12 +543,12 @@ try { } ``` -> In Observer mode after a host-side purchase, `await Purchasely.synchronize()` before chaining a follow-up placement so the receipt is uploaded first. +> **Resolving a `.purchase` / `.restore` interceptor with `'success'` already auto-synchronizes the receipt** — do not also call `Purchasely.synchronize()` from inside that handler. Reserve manual `synchronize()` calls for purchases processed **outside** the interceptor flow (e.g. a "Restore Purchases" button, or a client-side/BYOS presentation). If you need to chain a follow-up placement that targets users by subscription state, `await Purchasely.synchronize()` yourself first so the receipt is guaranteed to have landed before the fetch. ## Bridge & version alignment notes - The JS ↔ native bridge is still **NativeModules** (`Purchasely`) + event emitters. v6 changes the public JS surface, not the bridge transport. -- **All Purchasely npm packages MUST be the exact same version** (`6.0.0-rc.2`). Mixing versions causes runtime crashes. Pin exactly — never floating (`^6.0.0`, `6.x`). +- **All Purchasely npm packages MUST be the exact same version** (`6.0.0-rc.3`). Mixing versions causes runtime crashes. Pin exactly — never floating (`^6.0.0`, `6.x`). - Run a fresh install after pinning: `rm -rf node_modules && npm install`, then `pod install --repo-update` (iOS) and `./gradlew --refresh-dependencies` (Android) as needed. - See [`../sdk-versions.md`](../sdk-versions.md) for the canonical version table and [`./migration-v6.md`](./migration-v6.md) for the full v5 → v6 old→new mapping. diff --git a/purchasely/references/react-native/migration-v6.md b/purchasely/references/react-native/migration-v6.md index 58ace8f..b16943e 100644 --- a/purchasely/references/react-native/migration-v6.md +++ b/purchasely/references/react-native/migration-v6.md @@ -1,10 +1,10 @@ # React Native — Migrating to the Purchasely 6.0 API > **Published as a pre-release.** The React Native v6 API ships in -> `react-native-purchasely: 6.0.0-rc.2` (and the matching +> `react-native-purchasely: 6.0.0-rc.3` (and the matching > `@purchasely/react-native-purchasely-google` / `-android-player` / `-amazon` / -> `-huawei` packages), live on npm alongside the native iOS `Purchasely 6.0.0-rc.2` -> and Android `io.purchasely:core 6.0.0-rc.2` pre-releases. The builder-based API +> `-huawei` packages), live on npm alongside the native iOS `Purchasely 6.0.0-rc.3` +> and Android `io.purchasely:core 6.0.0-rc.3` pre-releases. The builder-based API > documented below (`Purchasely.builder`, `Purchasely.presentation`, > `Purchasely.interceptAction`) is the current published surface — the v5 > paywall API (`Purchasely.start({...})`, `fetchPresentation` / @@ -19,7 +19,7 @@ > [`../concepts/`](../concepts/). This release **adapts the Purchasely React Native plugin to the Purchasely 6.0 -native SDKs** (iOS `Purchasely 6.0.0-rc.2`, Android `io.purchasely:core 6.0.0-rc.2`). +native SDKs** (iOS `Purchasely 6.0.0-rc.3`, Android `io.purchasely:core 6.0.0-rc.3`). The public paywall symbols are **`PLY`-prefixed** — `Purchasely.builder`, `PLYPresentationBuilder`, `PLYPresentationRequest`, `PLYLoadedPresentation`, `PLYPresentationOutcome`, `PLYTransition`, `PLYInterceptResult`, … No `v6` / `V6` @@ -68,7 +68,7 @@ A paywall is now called a **Presentation** (or *Screen*). ## Migration checklist -1. Bump all five npm packages to **`6.0.0-rc.2`** exactly (`--save-exact`); never +1. Bump all five npm packages to **`6.0.0-rc.3`** exactly (`--save-exact`); never floating. `rm -rf node_modules && npm install`, then `pod install --repo-update`. Bump the Android host `minSdkVersion` to **23** (was 21) and `compileSdk` to 35. 2. Replace `Purchasely.start({...})` / `startWithAPIKey(...)` with the @@ -381,6 +381,10 @@ await Purchasely.builder('YOUR_API_KEY').allowDeeplink(true).start(); const handled = await Purchasely.handleDeeplink('app://ply/presentations/'); ``` +> `.allowDeeplink()` is an **optional** chain modifier — if you don't call it, the +> flag is never sent to native and the **native default (`true`)** applies, same +> as iOS/Android/Flutter. There is no RN-specific default of `false`. + There are **two distinct paywall flows** — don't conflate them: ### 1. Paywalls **you** display @@ -449,8 +453,14 @@ try { } ``` -> In Observer mode after a host-side purchase, `await Purchasely.synchronize()` -> before chaining a follow-up placement so the receipt is uploaded first. +> **Interceptor guidance.** Resolving a `.purchase` / `.restore` interceptor with +> `'success'` already **auto-synchronizes** the receipt — do not call +> `Purchasely.synchronize()` from inside that handler. Reserve manual +> `synchronize()` calls for purchases processed **outside** the interceptor flow +> (a "Restore Purchases" button, a client-side/BYOS presentation). If you need to +> chain a follow-up placement that targets users by subscription state, +> `await Purchasely.synchronize()` yourself first so the receipt has definitely +> landed before the fetch. --- @@ -495,10 +505,10 @@ exactly as in v5: > `userSubscriptionsHistory()`. > **Native dependency.** This React Native release targets the Purchasely v6 native -> SDKs (iOS `Purchasely 6.0.0-rc.2`, Android `io.purchasely:core 6.0.0-rc.2`), +> SDKs (iOS `Purchasely 6.0.0-rc.3`, Android `io.purchasely:core 6.0.0-rc.3`), > published as pre-releases on CocoaPods / Maven Central — see > [`../sdk-versions.md`](../sdk-versions.md) for the canonical pins. The published -> **React Native** packages are all `6.0.0-rc.2`, pinned exactly to those native +> **React Native** packages are all `6.0.0-rc.3`, pinned exactly to those native > versions. --- diff --git a/purchasely/references/react-native/v5-api-reference.md b/purchasely/references/react-native/v5-api-reference.md index 60d24d5..d7b3934 100644 --- a/purchasely/references/react-native/v5-api-reference.md +++ b/purchasely/references/react-native/v5-api-reference.md @@ -1,6 +1,6 @@ # React Native SDK v5.x API — reference for MIGRATION ONLY (removed in v6) -> **Do not write new v5 code.** This is a compact snapshot of the legacy v5.x public React Native API so the `purchasely-migrate` skill can **recognize** existing v5 code in a project and map it forward. Every paywall symbol below is **removed in v6.0.0-rc.2** (not deprecated — it fails to compile and no longer exists at runtime). For the v6 surface, see [`integration.md`](integration.md); for the step-by-step migration, see [`migration-v6.md`](migration-v6.md). +> **Do not write new v5 code.** This is a compact snapshot of the legacy v5.x public React Native API so the `purchasely-migrate` skill can **recognize** existing v5 code in a project and map it forward. Every paywall symbol below is **removed in v6.0.0-rc.3** (not deprecated — it fails to compile and no longer exists at runtime). For the v6 surface, see [`integration.md`](integration.md); for the step-by-step migration, see [`migration-v6.md`](migration-v6.md). Each entry adds a one-line `-> v6` pointer. diff --git a/purchasely/references/sdk-versions.md b/purchasely/references/sdk-versions.md index 6d2cddd..739025a 100644 --- a/purchasely/references/sdk-versions.md +++ b/purchasely/references/sdk-versions.md @@ -4,71 +4,77 @@ ## Current supported versions -_Last updated: 2026-07-07._ +_Last updated: 2026-07-22._ | Platform | Latest version | Notes | |----------|----------------|-------| -| **iOS** (native) | **6.0.0-rc.1** | Fluent init builder, per-action `interceptAction` + `PLYInterceptResult`, `PLYPresentationBuilder`, `swiftUIView`, `closeAllScreens()`, `PLYPresentationOutcome` (with `closeReason`). **Default running mode is now `.observer`** — set `.runningMode(.full)` for purchase handling. | -| **Android** (native) | **6.0.0-rc.1** | Presentation builder API, `screenId`, typed action interceptors, `PLYPresentationOutcome`. **Default running mode is now `Observer`** — set `PLYRunningMode.Full` for purchase handling. No `presentation-compose` artifact (use `AndroidView { buildView }` for Compose). | -| **React Native** | **6.0.0-rc.2** | v6 builder API: `Purchasely.builder` fluent init (string options), `Purchasely.presentation` (`PLYPresentationBuilder`) / `PLYPresentationRequest`, per-action `interceptAction` returning `'success' \| 'failed' \| 'notHandled'`, `PLYPresentationOutcome` (with `closeReason` = `button`/`backSystem`/`programmatic`). `isDeeplinkHandled` is **removed** — use `handleDeeplink(uri)`. `presentSubscriptions()` is **removed**. Pulls the **6.0.0-rc.2 native SDKs** (iOS `Purchasely` + Android `io.purchasely:core`). Requires **`minSdk 23`**. **Default running mode is now `'observer'`** — set `.runningMode('full')` for purchase handling. All five `react-native-purchasely*` packages MUST be the same version, pinned exactly. | -| **Flutter** | **6.0.0-rc.1** | v6 builder API: `PurchaselyBuilder` fluent init, `PresentationBuilder` / `PresentationRequest`, per-action `interceptAction` + `InterceptResult`, `PresentationOutcome` (with `closeReason`). Pulls the **6.0.0-rc.1 native SDKs** (iOS `Purchasely` + Android `io.purchasely:core`). **Default running mode is now `RunningMode.observer`** — set `.runningMode(RunningMode.full)` for purchase handling. All three `purchasely_*` packages MUST be the same version. | -| **Cordova** | **6.0.0-rc.1** | Method-based JS plugin (no builder API — it bridges the v6 native SDKs behind `cordova.exec` actions); pulls the **6.0.0-rc.2 native SDKs** (iOS `Purchasely` + Android `io.purchasely:core`). **Default running mode is now `observer`** — pass `Purchasely.RunningMode.full` for purchase handling. Three breaking surfaces: `start()` takes an **options object** (was positional); the action interceptor is **per-action** `interceptAction(kind, handler)` + `InterceptResult` (`setPaywallActionInterceptor` + `onProcessAction` removed); `isFullscreen` became a **display mode** (`TransitionType`). Deeplinks use `allowDeeplink` / `handleDeeplink` (+ `allowCampaigns`). All `@purchasely/cordova-plugin-*` packages MUST be the same version, pinned exactly. | +| **iOS** (native) | **6.0.0** | Stable GA (tagged 2026-07-20). Fluent init builder, per-action `interceptAction` + `PLYInterceptResult`, `PLYPresentationBuilder`, `swiftUIView`, `closeAllScreens()`, `PLYPresentationOutcome` (with `closeReason`). **Default running mode is `.observer`** — set `.runningMode(.full)` for purchase handling. Install via SPM (primary — `from: "6.0.0"`) or CocoaPods (`~> 6.0`); the SDK's dev repo is now SPM-only, so CocoaPods/binary distribution is published from the separate `Purchasely/Purchasely-iOS` repo. Deployment target **13.4+** — inherited from the 5.x SDK, not a v6 change. | +| **Android** (native) | **6.0.1** | Stable GA (tagged 2026-07-20 — no `6.0.0` tag was ever cut; chronology is `rc.1` → `rc.2` → `rc.3` → `6.0.1`). Presentation builder API, `screenId`, typed action interceptors, `PLYPresentationOutcome`. **Default running mode is `Observer`** — set `PLYRunningMode.Full` for purchase handling. No `presentation-compose` artifact (use `AndroidView { buildView }` for Compose); `google-play` / `huawei-services` / `amazon` / `player` artifacts stay in lockstep at `6.0.1`. | +| **Flutter** | **6.0.0** | Stable (published to pub.dev 2026-07-21). `purchasely_flutter`, `purchasely_google`, `purchasely_android_player` all `6.0.0`; embeds native iOS **6.0.0** + Android **`io.purchasely:core 6.0.1`**. Requires **Dart ≥ 3.0.0**. v6 builder API: `PurchaselyBuilder` fluent init, `PresentationBuilder` / `PresentationRequest`, per-action `interceptAction` + `InterceptResult`, `PresentationOutcome` (with `closeReason`). **Default running mode is `RunningMode.observer`** — set `.runningMode(RunningMode.full)` for purchase handling. All three `purchasely_*` packages MUST be the same version. | +| **React Native** | **6.0.0-rc.3** | Pre-release (npm dist-tag `latest`; GA `6.0.0` in preparation). v6 builder API: `Purchasely.builder` fluent init (string options), `Purchasely.presentation` (`PLYPresentationBuilder`) / `PLYPresentationRequest`, per-action `interceptAction` returning `'success' \| 'failed' \| 'notHandled'`, `PLYPresentationOutcome` (with `closeReason` = `button`/`backSystem`/`programmatic`). `isDeeplinkHandled` is **removed** — use `handleDeeplink(uri)`. `presentSubscriptions()` is **removed**. Pulls the **6.0.0-rc.3 native SDKs** (iOS `Purchasely` + Android `io.purchasely:core`, confirmed pinned at the published `6.0.0-rc.3` tag). Requires **`minSdk 23`**. **Default running mode is now `'observer'`** — set `.runningMode('full')` for purchase handling. All five `react-native-purchasely*` packages MUST be the same version, pinned exactly. | +| **Cordova** | **6.0.0-rc.3** | Pre-release (npm dist-tag `next` — `latest` is still `5.7.3`; install explicitly: `@purchasely/cordova-plugin-purchasely@6.0.0-rc.3`). Method-based JS plugin (no builder API — it bridges the v6 native SDKs behind `cordova.exec` actions); pulls the **6.0.0-rc.3 native SDKs** (iOS `Purchasely` + Android `io.purchasely:core`, confirmed pinned in `plugin.xml` at the published tag). **Default running mode is now `observer`** — pass `Purchasely.RunningMode.full` for purchase handling. Three breaking surfaces: `start()` takes an **options object** (was positional); the action interceptor is **per-action** `interceptAction(kind, handler)` + `InterceptResult` (`setPaywallActionInterceptor` + `onProcessAction` removed); `isFullscreen` became a **display mode** (`TransitionType`). Deeplinks use `allowDeeplink` / `handleDeeplink` (+ `allowCampaigns`). All `@purchasely/cordova-plugin-*` packages MUST be the same version, pinned exactly. | ## How to pin +### iOS — Swift Package Manager (primary) + +In Xcode → File → Add Packages → enter `https://github.com/Purchasely/Purchasely-iOS` and select **Up to Next Major Version**, `from: "6.0.0"`: + +```swift +.package(url: "https://github.com/Purchasely/Purchasely-iOS", from: "6.0.0") +``` + ### iOS — CocoaPods ```ruby # Podfile -pod 'Purchasely', '6.0.0-rc.1' +pod 'Purchasely', '~> 6.0' ``` -### iOS — Swift Package Manager - -In Xcode → File → Add Packages → enter `https://github.com/Purchasely/Purchasely-iOS` and select **Exact Version 6.0.0-rc.1**. +CocoaPods and binary distribution are published from the `Purchasely/Purchasely-iOS` repo (the SDK's own dev repo went SPM-only). ### iOS — Carthage ``` # Cartfile -binary "https://raw.githubusercontent.com/Purchasely/Purchasely-iOS/master/Purchasely.json" == 6.0.0-rc.1 +binary "https://raw.githubusercontent.com/Purchasely/Purchasely-iOS/master/Purchasely.json" ~> 6.0 ``` -Then run `carthage update`. Pin **exactly** (`== 6.0.0-rc.1`) — a floating constraint will not resolve a pre-release. +Then run `carthage update`. ### Android — Gradle (Kotlin DSL) ```kotlin // app/build.gradle.kts dependencies { - implementation("io.purchasely:core:6.0.0-rc.1") - implementation("io.purchasely:google-play:6.0.0-rc.1") // if Google Play - implementation("io.purchasely:player:6.0.0-rc.1") // optional video support + implementation("io.purchasely:core:6.0.1") + implementation("io.purchasely:google-play:6.0.1") // if Google Play + implementation("io.purchasely:player:6.0.1") // optional video support // alt stores - implementation("io.purchasely:huawei-services:6.0.0-rc.1") // Huawei AppGallery - implementation("io.purchasely:amazon:6.0.0-rc.1") // Amazon Appstore + implementation("io.purchasely:huawei-services:6.0.1") // Huawei AppGallery + implementation("io.purchasely:amazon:6.0.1") // Amazon Appstore } ``` ### Android — Gradle (Groovy) ```groovy -implementation "io.purchasely:core:6.0.0-rc.1" -implementation "io.purchasely:google-play:6.0.0-rc.1" +implementation "io.purchasely:core:6.0.1" +implementation "io.purchasely:google-play:6.0.1" ``` ### React Native — package.json -Pin **exactly** (`6.0.0-rc.2`, `npm install … --save-exact`) — a floating constraint (`^6.0.0`, `6.x`) will not resolve a pre-release. +Pre-release — pin **exactly** (`6.0.0-rc.3`, `npm install … --save-exact`). A floating constraint (`^6.0.0`, `6.x`) will not resolve a pre-release. ```json { "dependencies": { - "react-native-purchasely": "6.0.0-rc.2", - "@purchasely/react-native-purchasely-google": "6.0.0-rc.2", - "@purchasely/react-native-purchasely-android-player": "6.0.0-rc.2", - "@purchasely/react-native-purchasely-amazon": "6.0.0-rc.2", - "@purchasely/react-native-purchasely-huawei": "6.0.0-rc.2" + "react-native-purchasely": "6.0.0-rc.3", + "@purchasely/react-native-purchasely-google": "6.0.0-rc.3", + "@purchasely/react-native-purchasely-android-player": "6.0.0-rc.3", + "@purchasely/react-native-purchasely-amazon": "6.0.0-rc.3", + "@purchasely/react-native-purchasely-huawei": "6.0.0-rc.3" } } ``` @@ -77,57 +83,60 @@ Pin **exactly** (`6.0.0-rc.2`, `npm install … --save-exact`) — a floating co ```yaml dependencies: - purchasely_flutter: 6.0.0-rc.1 - purchasely_google: 6.0.0-rc.1 - purchasely_android_player: 6.0.0-rc.1 + purchasely_flutter: ^6.0.0 + purchasely_google: ^6.0.0 + purchasely_android_player: ^6.0.0 ``` -Pin **exactly** (`6.0.0-rc.1`) — a floating constraint will not resolve a pre-release. +Now stable — a caret range is fine for reproducible builds; pin exactly (`6.0.0`) if you prefer to control upgrades manually. ### Cordova — package.json +Pre-release, and npm's `latest` dist-tag still points to `5.7.3` — install with an explicit version (`--tag next` also works): + ```json { "dependencies": { - "@purchasely/cordova-plugin-purchasely": "6.0.0-rc.1", - "@purchasely/cordova-plugin-purchasely-google": "6.0.0-rc.1" + "@purchasely/cordova-plugin-purchasely": "6.0.0-rc.3", + "@purchasely/cordova-plugin-purchasely-google": "6.0.0-rc.3" } } ``` ## Cross-platform plugin → native dependency mapping -When you install a cross-platform plugin, it internally pulls a specific native SDK version. React Native, Flutter, and Cordova are on the v6 generation (React Native pins `6.0.0-rc.2`, Flutter `6.0.0-rc.1`, Cordova `6.0.0-rc.1`): +When you install a cross-platform plugin, it internally pulls a specific native SDK version. Flutter is stable GA; React Native and Cordova are still pre-release (React Native pins `6.0.0-rc.3`, Cordova `6.0.0-rc.3`): | Cross-platform plugin | Pulls iOS native | Pulls Android native | |-----------------------|------------------|----------------------| -| `react-native-purchasely 6.0.0-rc.2` | iOS SDK 6.0.0-rc.2 | Android SDK 6.0.0-rc.2 | -| `purchasely_flutter 6.0.0-rc.1` | iOS SDK 6.0.0-rc.1 | Android SDK 6.0.0-rc.1 | -| `@purchasely/cordova-plugin-purchasely 6.0.0-rc.1` | iOS SDK 6.0.0-rc.2 | Android SDK 6.0.0-rc.2 | +| `react-native-purchasely 6.0.0-rc.3` | iOS SDK 6.0.0-rc.3 | Android SDK 6.0.0-rc.3 | +| `purchasely_flutter 6.0.0` | iOS SDK 6.0.0 | Android SDK 6.0.1 | +| `@purchasely/cordova-plugin-purchasely 6.0.0-rc.3` | iOS SDK 6.0.0-rc.3 | Android SDK 6.0.0-rc.3 | -This means a v6 cross-platform plugin gets its pinned v6 native SDKs transitively (React Native → `6.0.0-rc.2`, Flutter → `6.0.0-rc.1`, Cordova → `6.0.0-rc.2`). You do not need to bump the native pods/gradle dependencies yourself; the plugin's pinning is correct. +This means a cross-platform plugin gets its pinned native SDKs transitively (React Native → `6.0.0-rc.3`, Flutter → iOS `6.0.0` / Android `6.0.1`, Cordova → `6.0.0-rc.3`). You do not need to bump the native pods/gradle dependencies yourself; the plugin's pinning is correct. > If a user is on a Cordova plugin version older than `6.0.0-rc.1`, v6 native behavior may not be bridged. Upgrade the plugin first, then verify the public bridge method name in that platform's integration reference. -> **React Native is on the v6 API** (same generation as native iOS / Android). All five `react-native-purchasely*` packages at `6.0.0-rc.2` pull the **6.0.0-rc.2 native SDKs** and expose the v6 JS surface: `Purchasely.builder` fluent init with string options (replacing `Purchasely.start({...})`), `Purchasely.presentation` (`PLYPresentationBuilder`) / `PLYPresentationRequest` (replacing `fetchPresentation` / `presentPresentation[ForPlacement]`), per-action `Purchasely.interceptAction` returning `'success' \| 'failed' \| 'notHandled'` (replacing `setPaywallActionInterceptorCallback` + `onProcessAction`), and `request.close()` to dismiss. `isDeeplinkHandled(uri)` / `readyToOpenDeeplink(bool)` are **removed** — use `Purchasely.handleDeeplink(uri)` and `.allowDeeplink(true)`. `Purchasely.presentSubscriptions()` is **removed** (breaking) — build your own screen from `userSubscriptions()` / `userSubscriptionsHistory()`. Requires Android `minSdk 23`. See [`react-native/migration-v6.md`](react-native/migration-v6.md) and [`react-native/integration.md`](react-native/integration.md). Pin all packages to `6.0.0-rc.2` exactly (`--save-exact`). +> **React Native is on the v6 API** (same generation as native iOS / Android), still a pre-release. All five `react-native-purchasely*` packages at `6.0.0-rc.3` pull the **6.0.0-rc.3 native SDKs** and expose the v6 JS surface: `Purchasely.builder` fluent init with string options (replacing `Purchasely.start({...})`), `Purchasely.presentation` (`PLYPresentationBuilder`) / `PLYPresentationRequest` (replacing `fetchPresentation` / `presentPresentation[ForPlacement]`), per-action `Purchasely.interceptAction` returning `'success' \| 'failed' \| 'notHandled'` (replacing `setPaywallActionInterceptorCallback` + `onProcessAction`), and `request.close()` to dismiss. `isDeeplinkHandled(uri)` / `readyToOpenDeeplink(bool)` are **removed** — use `Purchasely.handleDeeplink(uri)` and `.allowDeeplink(true)`. `Purchasely.presentSubscriptions()` is **removed** (breaking) — build your own screen from `userSubscriptions()` / `userSubscriptionsHistory()`. Requires Android `minSdk 23`. See [`react-native/migration-v6.md`](react-native/migration-v6.md) and [`react-native/integration.md`](react-native/integration.md). Pin all packages to `6.0.0-rc.3` exactly (`--save-exact`); GA `6.0.0` is in preparation. -> **Flutter is on the v6 API** (same generation as native iOS / Android). `purchasely_flutter 6.0.0-rc.1` pulls the **6.0.0-rc.1 native SDKs** and exposes the v6 Dart surface: `PurchaselyBuilder` fluent init, `PresentationBuilder` / `PresentationRequest` (replacing `fetchPresentation` / `presentPresentation[ForPlacement]`), per-action `interceptAction` + `InterceptResult` (replacing `setPaywallActionInterceptorCallback` + `onProcessAction`), and `presentation.close()` to dismiss (there is no `closePresentation()` / `closeAllScreens()` in Flutter v6). `Purchasely.presentSubscriptions()` is **removed** (breaking) — build your own screen from `userSubscriptions()` / `userSubscriptionsHistory()`. See [`flutter/migration-v6.md`](flutter/migration-v6.md) and [`flutter/integration.md`](flutter/integration.md). Pin `purchasely_flutter: 6.0.0-rc.1`. +> **Flutter is on the v6 API** (same generation as native iOS / Android) and is now **stable**. `purchasely_flutter 6.0.0` pulls native iOS **`6.0.0`** + Android **`io.purchasely:core 6.0.1`** and exposes the v6 Dart surface: `PurchaselyBuilder` fluent init, `PresentationBuilder` / `PresentationRequest` (replacing `fetchPresentation` / `presentPresentation[ForPlacement]`), per-action `interceptAction` + `InterceptResult` (replacing `setPaywallActionInterceptorCallback` + `onProcessAction`), and `presentation.close()` to dismiss (there is no `closePresentation()` / `closeAllScreens()` in Flutter v6). `Purchasely.presentSubscriptions()` is **removed** (breaking) — build your own screen from `userSubscriptions()` / `userSubscriptionsHistory()`. Requires **Dart ≥ 3.0.0**. See [`flutter/migration-v6.md`](flutter/migration-v6.md) and [`flutter/integration.md`](flutter/integration.md). Pin `purchasely_flutter: 6.0.0` (or `^6.0.0`). -> **Cordova is on the v6 API** (same generation as native iOS / Android). `@purchasely/cordova-plugin-purchasely 6.0.0-rc.1` pulls the **6.0.0-rc.2 native SDKs** and keeps a **method-based JS surface** (no builder API), but with three breaking surfaces: `Purchasely.start(options, success, error)` now takes a **single options object** (the v5 positional list is gone); the action interceptor is **per-action** `interceptAction(kind, handler)` returning an `InterceptResult` (`setPaywallActionInterceptor` + `onProcessAction` were removed, `PaywallAction` renamed to `PresentationAction`); and the `isFullscreen` boolean on `present*` became a **display mode** (`TransitionType` string / boolean / transition object). Unchanged: `fetchPresentation` / `fetchPresentationForPlacement`, `presentPresentation[ForPlacement]`, and `closePresentation()`. Other renames: `allowDeeplink` / `handleDeeplink` (+ new `allowCampaigns`) replaces `readyToOpenDeeplink` / `isDeeplinkHandled`; `setDefaultPresentationDismissHandler` replaces `setDefaultPresentationResultHandler`; `RunningMode` values are name strings `'observer'` / `'full'`; `synchronize(success, error)` now reports completion. Removed: `presentSubscriptions()`, `presentProductWithIdentifier()`, `presentPlanWithIdentifier()`, `showPresentation()`, and `hidePresentation()`. See [`cordova/migration-v6.md`](cordova/migration-v6.md) and [`cordova/integration.md`](cordova/integration.md). Pin all Cordova packages to `6.0.0-rc.1` exactly. +> **Cordova is on the v6 API** (same generation as native iOS / Android), still a pre-release. `@purchasely/cordova-plugin-purchasely 6.0.0-rc.3` pulls the **6.0.0-rc.3 native SDKs** and keeps a **method-based JS surface** (no builder API), but with three breaking surfaces: `Purchasely.start(options, success, error)` now takes a **single options object** (the v5 positional list is gone); the action interceptor is **per-action** `interceptAction(kind, handler)` returning an `InterceptResult` (`setPaywallActionInterceptor` + `onProcessAction` were removed, `PaywallAction` renamed to `PresentationAction`); and the `isFullscreen` boolean on `present*` became a **display mode** (`TransitionType` string / boolean / transition object). Unchanged: `fetchPresentation` / `fetchPresentationForPlacement`, `presentPresentation[ForPlacement]`, and `closePresentation()`. Other renames: `allowDeeplink` / `handleDeeplink` (+ new `allowCampaigns`) replaces `readyToOpenDeeplink` / `isDeeplinkHandled`; `setDefaultPresentationDismissHandler` replaces `setDefaultPresentationResultHandler`; `RunningMode` values are name strings `'observer'` / `'full'`; `synchronize(success, error)` now reports completion. Removed: `presentSubscriptions()`, `presentProductWithIdentifier()`, `presentPlanWithIdentifier()`, `showPresentation()`, and `hidePresentation()`. See [`cordova/migration-v6.md`](cordova/migration-v6.md) and [`cordova/integration.md`](cordova/integration.md). Pin all Cordova packages to `6.0.0-rc.3` exactly — npm's `latest` dist-tag is still `5.7.3`, so install with an explicit version (or `--tag next`). ## Universal rules -1. **All plugin packages on the same version.** Mixing `react-native-purchasely 6.0.0-rc.2` with `@purchasely/react-native-purchasely-google 5.7.3`, or `@purchasely/cordova-plugin-purchasely 6.0.0-rc.1` with `@purchasely/cordova-plugin-purchasely-google 5.7.3`, causes runtime crashes. -2. **Use exact versions** (for example `6.0.0-rc.1`, `6.0.0-rc.2`, or `5.7.5`), not floating versions (`6.+`, `5.+`, `^6.0.0-rc.1`, `^5.0.0`). Floating versions silently pull breaking changes on `pod install` / `flutter pub get` / `npm install`. -3. **iOS deployment target: 11.0+** (13.4+ for the v6 native, Flutter, React Native, and Cordova SDKs). Older targets break the Pod install. -4. **Android `minSdk` 23 for native, React Native v6, and Cordova v6.** Native Android v6 also targets the AGP 9 / Kotlin 2.2 toolchain. +1. **All plugin packages on the same version.** Mixing `react-native-purchasely 6.0.0-rc.3` with `@purchasely/react-native-purchasely-google 5.7.3`, or `@purchasely/cordova-plugin-purchasely 6.0.0-rc.3` with `@purchasely/cordova-plugin-purchasely-google 5.7.3`, causes runtime crashes. +2. **Exact pins are required for pre-release SDKs.** React Native (`6.0.0-rc.3`) and Cordova (`6.0.0-rc.3`) are still pre-releases — pin them exactly (`npm install … --save-exact`); a floating range (`^6.0.0`, `6.x`) will not resolve a pre-release. **GA/stable SDKs** — iOS native `6.0.0`, Android native `6.0.1`, Flutter `6.0.0` — can safely use a semver-compatible range (`~> 6.0`, `from: "6.0.0"`, `^6.0.0`) so patch fixes flow automatically; pin exactly instead if you prefer to control upgrades manually. +3. **iOS deployment target: 13.4+** across all Purchasely SDKs (native, Flutter, React Native, Cordova) — this floor is inherited from the 5.x SDK, not a v6-specific change. Older targets break the Pod install. +4. **Android toolchain (native 6.0.1, and the AGP/Kotlin floor for React Native v6 / Cordova v6 host apps):** Gradle **≥ 9.3** (the SDK's own dev wrapper runs 9.6.1), AGP **9.0.1**, **Kotlin 2.3.21** (fixes issues present in the 2.2.x line used by early v6 release candidates), JDK 17 to build, `minSdk 23`, `compileSdk 36`, `targetSdk 35`, Google Play Billing **8.3.0**. 5. **Run a fresh install after pinning** — `pod install --repo-update` (iOS), `./gradlew --refresh-dependencies` (Android), `flutter clean && flutter pub get` (Flutter), `rm -rf node_modules && npm i` (RN / Cordova). ## When to upgrade Always recommend upgrading to the versions above when: -- The native Android project pins a pre-v6 version but the user wants the v6 Presentation builder API. +- The native Android project pins a v6 release candidate (`rc.1` / `rc.2` / `rc.3`) — jump straight to the stable `6.0.1` (no `6.0.0` tag was ever cut for Android). +- The native iOS project pins a pre-`6.0.0` release candidate — move to the stable `6.0.0` tag. - The project uses floating versions (`5.+`, `^5.0.0`) — pin to exact stable for reproducible builds. - The user is debugging issues that match a known fixed-in-5.7.x bug — see the platform changelog. diff --git a/purchasely/references/troubleshooting/common-issues.md b/purchasely/references/troubleshooting/common-issues.md index 6416733..573e9ad 100644 --- a/purchasely/references/troubleshooting/common-issues.md +++ b/purchasely/references/troubleshooting/common-issues.md @@ -58,13 +58,13 @@ Annotated slice for one Observer-mode purchase (placement IDs are app-specific } [Purchasely] Event: IN_APP_RENEWED ← subscription confirmed active [Purchasely] Interceptor executed action purchase. Skipping SDK execution. - ↑ proceed(false) acknowledged + ↑ your resolved intercept result (e.g. `.success`) acknowledged [Purchasely] Event: PRESENTATION_CLOSED ← paywall dismissed ``` The trace tells you, in order: 1. **Receipt validated** (`RECEIPT_VALIDATED`, `IN_APP_RENEWED`) — purchase succeeded server-side. -2. **Interceptor acknowledged** (`Skipping SDK execution`) — your `proceed(false)` was received. +2. **Interceptor acknowledged** (`Skipping SDK execution`) — your resolved intercept result (`PLYInterceptResult.success` / `'success'` / etc., see [paywall-actions.md](../concepts/paywall-actions.md)) was received. 3. **Paywall dismissed** (`PRESENTATION_CLOSED`) — the platform's dismiss API ran (`closeAllScreens()` on native iOS/Android, `presentation.close()` on Flutter v6, `request.close()` on React Native v6, `closePresentation()` on Cordova v6). If you chain a follow-up placement after the purchase, expect an additional `Successfully retrieved presentation Optional("")` → `PRESENTATION_LOADED` → `PRESENTATION_VIEWED` sequence at the end of the trace. @@ -188,32 +188,34 @@ When a teammate says "paywall is broken", ask in this order: ## 1. Paywall Not Showing -**Symptoms:** `presentationController` or `fetchPresentation` returns nil/null, paywall never appears. +**Symptoms:** `PLYPresentationBuilder(...).build().preload()` returns nil/throws, or the presentation never displays. **Causes and Solutions:** -- **SDK not initialized:** Ensure `Purchasely.start()` has completed successfully before calling any presentation method. Wait for the `success == true` callback. +- **SDK not initialized:** Ensure `Purchasely.apiKey(...).start()` has completed successfully before calling any presentation method. Wait for the `start()` completion (`error == nil`) or the awaited call to return without throwing. - **Invalid placement ID:** Verify the placement vendor ID in the Purchasely dashboard matches exactly (case-sensitive). -- **Presentation type is DEACTIVATED:** Always check `presentation.type` before displaying. A deactivated presentation returns valid data but should not be shown. -- **Wrong thread (iOS):** On iOS, `Purchasely.start()` must be called on the main thread. Calling from a background queue can silently fail. +- **Presentation type is DEACTIVATED:** Always check `presentation.type` before displaying — see [presentation-types.md](../concepts/presentation-types.md). A deactivated presentation returns valid data but should not be shown. +- **Wrong thread (iOS):** On iOS, `start()` must be called on the main thread. Calling from a background queue can silently fail. - **No active presentation:** Ensure a presentation is assigned to the placement in the dashboard. ```swift -// iOS: Verify initialization before presenting -Purchasely.start(withAPIKey: "KEY", storekitSettings: .storeKit2) { success, error in - guard success else { - print("SDK not ready: \(error?.localizedDescription ?? "")") +// iOS v6: Verify initialization before presenting +Purchasely.apiKey("KEY").storekitSettings(.storeKit2).start { error in + guard error == nil else { + print("SDK not ready: \(error!.localizedDescription)") return } - // Now safe to present + // Now safe to build and preload a presentation } ``` +> **Legacy (v5).** `Purchasely.start(withAPIKey:storekitSettings:completion:)` with a `(success, error)` 2-parameter completion, and `Purchasely.presentationController(for:)`, were removed in v6 in favour of the builder (`Purchasely.apiKey(...).start { error in }`) and `PLYPresentationBuilder`. + ## 2. UI Frozen / Paywall Stuck **Symptoms:** Paywall buttons stop responding, spinner never dismisses, app appears frozen. -**Cause:** the action was not acknowledged in all code paths of the interceptor — a returned `PLYInterceptResult` on native iOS/Android v6, a returned `InterceptResult` (`success` / `failed` / `notHandled`) on Flutter v6, a returned `'success' / 'failed' / 'notHandled'` string on React Native v6, or a returned/resolved `Purchasely.InterceptResult` on Cordova v6. +**Cause:** the action was not acknowledged in all code paths of the interceptor — a returned `PLYInterceptResult` (`success` / `failed` / `notHandled`) on native iOS/Android v6 and Flutter v6, a returned `'success' / 'failed' / 'notHandled'` string on React Native v6, or a returned/resolved `Purchasely.InterceptResult` on Cordova v6. **Solution:** Ensure every branch resolves exactly once. Native iOS/Android v6, Flutter v6, React Native v6, and Cordova v6 all return or resolve a result. @@ -229,9 +231,9 @@ Purchasely.interceptAction(.login) { _, _ in **Flutter v6:** ```dart -await Purchasely.interceptAction(PresentationActionKind.login, (info, payload) async { +await Purchasely.interceptAction(PLYPresentationActionKind.login, (info, payload) async { final ok = await showLogin(); - return ok ? InterceptResult.success : InterceptResult.notHandled; + return ok ? PLYInterceptResult.success : PLYInterceptResult.notHandled; }); ``` @@ -308,21 +310,23 @@ override fun onCreate(savedInstanceState: Bundle?) { **Causes and Solutions:** - **`handleDeeplink` not called:** Ensure you call `Purchasely.handleDeeplink(url)` (iOS) or `Purchasely.handleDeeplink(uri, activity)` (Android) in your deeplink handler. -- **`readyToOpenDeeplink` not set:** The SDK queues deeplinks until `readyToOpenDeeplink` is set to `true`. Call this when your root view controller / main activity is ready. +- **`allowDeeplink` not set:** The SDK queues deeplinks until `allowDeeplink` is `true` (the v6 default, but check for an explicit `allowDeeplink(false)` left over from a gated onboarding flow that never flips back). Call `Purchasely.allowDeeplink(true)` when your root view controller / main activity is ready if you gated it. - **URL scheme not configured:** Verify the URL scheme or universal link / app link is properly configured in your app settings. - **SDK not initialized:** If the deeplink arrives before `start()` completes, it will be lost. Initialize the SDK as early as possible. ```swift -// iOS: Handle deeplink in SceneDelegate +// iOS v6: Handle deeplink in SceneDelegate func scene(_ scene: UIScene, openURLContexts URLContexts: Set) { guard let url = URLContexts.first?.url else { return } Purchasely.handleDeeplink(url) } -// Signal ready -Purchasely.readyToOpenDeeplink(true) +// Only needed if you gated deeplinks with allowDeeplink(false) at init: +Purchasely.allowDeeplink(true) ``` +> **Legacy (v5).** `Purchasely.readyToOpenDeeplink(true)` was renamed `Purchasely.allowDeeplink(true)` in v6 — see [campaigns.md](../concepts/campaigns.md#sdk-setup--gating-campaign-display). + ## 7. User Attributes Not Syncing **Symptoms:** Audience targeting based on attributes does not work, attributes appear empty in the dashboard. @@ -332,12 +336,13 @@ Purchasely.readyToOpenDeeplink(true) **Solution:** Set attributes only after the SDK initialization callback confirms success: ```kotlin +// v6: the builder's start() completion takes a single nullable PLYError Purchasely.Builder(applicationContext) .apiKey("KEY") .stores(listOf(GoogleStore())) .build() - .start { success, error -> - if (success) { + .start { error -> + if (error == null) { // NOW safe to set attributes Purchasely.setUserAttribute("tier", "premium") Purchasely.setUserAttribute("articles_read", 42) @@ -345,6 +350,8 @@ Purchasely.Builder(applicationContext) } ``` +> **Legacy (v5).** The 2-parameter `start { success, error -> }` callback was replaced by a single nullable `error` parameter in the v6 builder's `start(...)`. + **Related — campaign on a custom-attribute audience is hit-or-miss on the first launch:** `setUserAttribute(...)` saves the value but does **not** re-evaluate any campaign. A trigger-based campaign evaluates its audience when the trigger resolves (default `APP_STARTED` → shortly after start), using the attributes held at that moment. If the attribute is set after that, the audience won't match on the **first** launch; because the value is persisted in the SDK's disk cache, it matches **from the next session** (hence the "it worked once" symptom). To make it reliable on first launch, gate campaigns until attributes are set — `allowCampaigns(false)` → `setUserAttribute(...)` → `allowCampaigns(true)` (ordering: start → set attributes → allow campaigns). See [campaigns.md](../concepts/campaigns.md#custom-attribute-audiences-set-the-attribute-before-campaigns-are-evaluated). ## 8. Paywall Disappears Immediately @@ -355,24 +362,34 @@ Purchasely.Builder(applicationContext) **Solutions:** -**iOS:** Hold a strong reference to the controller: +**iOS:** Hold a strong reference to the controller. In v6, prefer `presentation.display(from:)` (the SDK owns the reference and Flow close controls); only reach for the raw `presentation.controller` when you need to embed it yourself: ```swift -// BAD: Controller is deallocated immediately -func showPaywall() { - let vc = Purchasely.presentationController(for: "ONBOARDING") +// BAD: the raw controller is not retained, so it may be deallocated immediately +func showPaywall() async throws { + let presentation = try await PLYPresentationBuilder.forPlacementId("ONBOARDING").build().preload() + let vc = presentation?.controller present(vc!, animated: true) // vc may be deallocated } -// GOOD: Present modally (UIKit retains it) or store as property +// GOOD (preferred): let the SDK own display + retention +func showPaywall() async throws { + let presentation = try await PLYPresentationBuilder.forPlacementId("ONBOARDING").build().preload() + presentation?.display(from: self) +} + +// GOOD (if you must embed it yourself): store the controller as a property var paywallController: UIViewController? -func showPaywall() { - paywallController = Purchasely.presentationController(for: "ONBOARDING") +func showPaywall() async throws { + let presentation = try await PLYPresentationBuilder.forPlacementId("ONBOARDING").build().preload() + paywallController = presentation?.controller present(paywallController!, animated: true) } ``` +> **Legacy (v5).** `Purchasely.presentationController(for:)` was removed in v6 in favour of `PLYPresentationBuilder` + `preload()`, exposing `presentation.display(from:)` or `presentation.controller`. + **Android:** Ensure the Fragment is properly attached to a container and the Activity is not finishing: ```kotlin @@ -468,15 +485,18 @@ The SDK holds flow presentations inside a dedicated `PLYWindow` (iOS) / custom o **Diagnosis:** Look at the interceptor action and the flow's step count: ```swift -// In your PaywallActionsInterceptor -Purchasely.setPaywallActionsInterceptor { action, params, info, proceed in - print("Action: \(action) rawValue=\(action.rawValue)") - // rawValue 0 = .close, rawValue 1 = .closeAll - proceed(true) +// iOS v6 — register per-action interceptors +Purchasely.interceptAction(.close) { info, params in + print("Action: close") + return .notHandled // let the SDK run its default behaviour while diagnosing +} +Purchasely.interceptAction(.closeAll) { info, params in + print("Action: closeAll") + return .notHandled } ``` -If you see `rawValue: 0` (`.close`) fired from what the user perceives as "exit the paywall", the **paywall is misconfigured**. +If `.close` fires from what the user perceives as "exit the paywall", the **paywall is misconfigured**. **Solution (preferred): fix the Console configuration** @@ -490,23 +510,26 @@ If you see `rawValue: 0` (`.close`) fired from what the user perceives as "exit If you cannot modify the Console config (e.g. legacy paywalls, A/B tests), map `.close` to `.closeAll` in your interceptor: ```swift -// iOS — in the paywall actions interceptor -case .close: +// iOS v6 — in the .close action interceptor +Purchasely.interceptAction(.close) { info, params in // Treat X as full exit, not back navigation - proceed(false) // we handled it Purchasely.closeAllScreens() + return .success // we handled it +} ``` ```kotlin -// Android — in the paywall actions interceptor -PLYPresentationAction.CLOSE -> { - processAction(false) +// Android v6 — in the Close action interceptor +Purchasely.interceptAction { info, _ -> Purchasely.closeAllScreens() + PLYInterceptResult.SUCCESS } ``` **Why clients don't hit this in prod:** most customer paywalls created via the Screen Composer default to `.closeAll` on their dismiss button, because that matches the "exit paywall" user intent. The bug surfaces on legacy or hand-configured paywalls that use `.close` on a single-step flow. +> **Legacy (v5).** `Purchasely.setPaywallActionsInterceptor { action, params, info, proceed in }` (one global callback, `action.rawValue` switch, `proceed(Bool)`) and the Android `processAction(Boolean)` companion were removed in v6 in favour of one `interceptAction(...)` registration per action kind, returning a `PLYInterceptResult` — see [paywall-actions.md](../concepts/paywall-actions.md). + **Related defensive work:** see Purchasely-iOS-Sources PR #563 which adds SDK-level safeguards (`closeFlow()` called when no visible content remains) so misconfigured paywalls degrade gracefully instead of freezing. ## 12. Dynamic Offering Billing Type Resolves to `.unspecified` (iOS Commitment) diff --git a/purchasely/references/troubleshooting/debug-mode.md b/purchasely/references/troubleshooting/debug-mode.md index 2c9da52..de4a447 100644 --- a/purchasely/references/troubleshooting/debug-mode.md +++ b/purchasely/references/troubleshooting/debug-mode.md @@ -31,14 +31,14 @@ Enable both together when investigating "the wrong paywall appears" tickets. | iOS (Swift) | `Purchasely.logLevel = .debug` (or pass `logLevel: .debug` to `start`) | | Android (Kotlin) | `.logLevel(LogLevel.DEBUG)` on the `Purchasely.Builder` | | React Native (v6) | `.logLevel('debug')` on the `Purchasely.builder(...)` | -| Flutter | `.logLevel(LogLevel.debug)` on the `PurchaselyBuilder` | +| Flutter | `.logLevel(PLYLogLevel.debug)` on the `PurchaselyBuilder` | | Cordova | `Purchasely.LogLevel.DEBUG` as the 4th argument to `Purchasely.start(...)` | > **Gate behind a build flag.** Ship `LogLevel.ERROR` (or omit the parameter) in production. Debug logs include placement IDs, audience matches, and presentation IDs — keep them out of production binaries. ## Enabling Debug Mode -> ⚠️ **Deeplink handling is required.** Your app must implement `Purchasely.handleDeeplink(...)` and call `Purchasely.readyToOpenDeeplink(true)` once the app's UI is ready. Without it, the QR code does nothing. See [campaigns.md](../concepts/campaigns.md#sdk-setup--readytoopendeeplink). +> ⚠️ **Deeplink handling is required.** Your app must implement `Purchasely.handleDeeplink(...)` and ensure `allowDeeplink` is `true` (the v6 default — set `Purchasely.allowDeeplink(true)` once the app's UI is ready if you gated it). Without it, the QR code does nothing. See [campaigns.md](../concepts/campaigns.md#sdk-setup--gating-campaign-display). ### Step 1 — Get the preview QR code @@ -78,7 +78,7 @@ Set its **priority** higher than any other audience the device might match — o ## Anti-patterns - ❌ **Forgetting to deactivate Debug Mode.** Devices left in Debug Mode keep matching Internal Testers — leading to "this user sees the test paywall in production" tickets. -- ❌ **Skipping deeplink setup.** The QR is a deeplink. If `handleDeeplink` isn't wired and `readyToOpenDeeplink(true)` hasn't been called after the splash, nothing happens. +- ❌ **Skipping deeplink setup.** The QR is a deeplink. If `handleDeeplink` isn't wired, or `allowDeeplink` was gated `false` at init and never flipped back to `true` after the splash, nothing happens. - ❌ **Using Debug Mode to bypass purchase validation.** Debug Mode previews draft Screens; it does **not** simulate purchases. Use [sandbox testing](../testing/README.md) for that. - ❌ **Testing Debug Mode in a release build with `logLevel = ERROR`.** The Debug Panel works either way, but the SDK debug log stream — your primary diagnostic tool — won't be visible. diff --git a/purchasely/references/troubleshooting/screen-issue-report.md b/purchasely/references/troubleshooting/screen-issue-report.md index 40b59b4..44a2e80 100644 --- a/purchasely/references/troubleshooting/screen-issue-report.md +++ b/purchasely/references/troubleshooting/screen-issue-report.md @@ -106,7 +106,7 @@ Attach a grep of `[Purchasely]` over the failing run. If the issue involves a Fl | Screenshots / recording | Shows whether the issue is rendering (missing component) or logic (wrong offer) | | Display method | A bug that reproduces via Placement but not via direct presentationId narrows down to audience / targeting | | SDK version | Many "missing API" tickets are version pins below the feature's minimum (see [sdk-versions.md](../sdk-versions.md)) | -| Plugin alignment | Cross-platform: a `react-native-purchasely 6.0.0-rc.2` + `@purchasely/react-native-purchasely-google 5.6.0` mismatch produces silent rendering bugs — pin every Purchasely package to the exact same version | +| Plugin alignment | Cross-platform: a `react-native-purchasely 6.0.0-rc.3` + `@purchasely/react-native-purchasely-google 5.6.0` mismatch produces silent rendering bugs — pin every Purchasely package to the exact same version | | User context | Targeting bugs reproduce only for users matching the broken audience | | Logs | The SDK log stream usually contains the root cause — see [common-issues.md §0](common-issues.md#0-diagnostic-logs--read-before-patching) | | Recent changes | An issue that started at a specific date correlates with an SDK upgrade or a Console edit | diff --git a/purchasely/references/troubleshooting/support-known-issues.md b/purchasely/references/troubleshooting/support-known-issues.md index 859bd5a..77f055e 100644 --- a/purchasely/references/troubleshooting/support-known-issues.md +++ b/purchasely/references/troubleshooting/support-known-issues.md @@ -2,6 +2,8 @@ Use this file when a user describes a symptom that matches a known support pattern. These are not generic SDK rules; verify SDK version, running mode, logs, and Console configuration before applying a fix. +> **Historical entries use v5 names.** Several entries below predate the v6 rename pass and still use v5 terminology verbatim. When applying an older entry to a v6 integration, map: `readyToOpenDeeplink` → `allowDeeplink`; `PaywallObserver` → `Observer`; `proceed(...)` / `processAction(...)` → the per-action `interceptAction(...)` result (`PLYInterceptResult` / string / etc., see [paywall-actions.md](../concepts/paywall-actions.md)). + ## iOS internal Open Placement child modal swipe dismissal **Symptom:** an internal Open Placement action opens a child modal; the user swipes the child down and the parent Screen does not receive the expected dismissal callback/interceptor state. @@ -29,7 +31,7 @@ Use this file when a user describes a symptom that matches a known support patte **Symptom:** annual subscription billed monthly displays a confusing total/monthly price combination on iOS 26. -**Known fix:** target the Purchasely SDK 6.0/6.1 line when available for updated StoreKit handling. Be explicit that Apple StoreKit has a total-price display limitation for this billing style; the SDK cannot always force the exact merchandising copy the customer wants. +**Known fix:** target the Purchasely SDK 6.0.x line for updated StoreKit handling. Be explicit that Apple StoreKit has a total-price display limitation for this billing style; the SDK cannot always force the exact merchandising copy the customer wants. ## Android promo-code / developer-determined offer placements @@ -54,3 +56,57 @@ Use this file when a user describes a symptom that matches a known support patte **Symptom:** an anonymous subscriber later logs in, but downstream systems do not merge the anonymous and identified histories correctly. **Known fix:** rely on Purchasely's user migration webhooks for the subscription ownership transfer, and handle Braze/profile merging separately. Do not assume the Braze merge is automatic just because Purchasely transferred the receipt; wire the exact webhook events used by the backend/CRM pipeline and test anonymous -> identified migration end to end. + +## Android: paywall not translated on Indonesian devices (< 6.0.1) + +**Symptom:** on a device set to Indonesian, the paywall renders in the fallback/default language instead of the configured Indonesian translation, even though the translation exists on the dashboard. + +**Known fix:** Android resource-qualifier mismatch — translations shipped under `values-id`, but Android actually resolves Indonesian locales against the qualifier `values-in` (Android still uses the legacy ISO 639-1 code `in` for Indonesian, not the more common `id`). Fixed in SDK **6.0.1** by shipping under the correct qualifier — upgrade rather than patching app-side resources. + +## Flows: `PRESENTATION_VIEWED` missing at high volume (< 6.0.0-rc.3) + +**Symptom:** on Flows with many steps/placements viewed in a single session, some `PRESENTATION_VIEWED` events are missing from the analytics stream — typically the earliest ones in a long session. + +**Known fix:** an internal FIFO buffer tracking already-viewed presentations evicted entries once full (cap of 100), silently dropping the event for evicted entries under high-volume Flow usage. Fixed in **6.0.0-rc.3** by raising the cap to 200. Upgrade; don't build app-side dedup/backfill logic to work around it. + +## iOS: app freezes on iPad after closing a campaign (fixed 6.0.0) + +**Symptom:** on iPad specifically, dismissing a campaign-triggered presentation freezes the app — no crash, the UI simply stops responding. + +**Known fix:** root cause was key-window restoration after the campaign's window was torn down — on iPad the previous key window wasn't always correctly restored, leaving the app without a responsive window. Fixed in SDK **6.0.0** — upgrade rather than adding app-side `makeKeyAndVisible()` workarounds, which don't reliably fix it. + +## Callbacks on a preloaded presentation never fire (fixed rc.2) + +**Symptom:** a presentation is preloaded well ahead of display; when the user later triggers `display()`, none of the lifecycle callbacks (`onPresented`, `onDismissed`, etc.) ever fire — no error logged either. + +**Known fix:** the preloaded presentation was silently deallocated between preload and display when the app didn't keep a strong reference to it (or to the request that produced it) — nothing was logged to indicate this. Fixed in SDK **6.0.0-rc.2**. Regardless of the fix, keep a reference to the built request / loaded presentation — see [presentation-cache.md](../concepts/presentation-cache.md). + +## Back button right-aligned / icon-text order reversed (fixed 6.0.0) + +**Symptom:** the paywall's back button renders on the right side with the icon and label order swapped from what's configured in the Screen Composer. + +**Known fix:** a rendering-engine layout bug affecting back-button composition. Fixed in SDK **6.0.0** — upgrade rather than compensating via Screen Composer styling tweaks. + +## Video `autoplay: false` ignored (open bug in 6.0.0) + +**Symptom:** a paywall video block configured with `autoplay: false` still starts playing automatically as soon as the Screen appears. + +**Known fix:** none yet — this is an **open bug** in SDK **6.0.0**. There is no reliable app-side workaround (the video component doesn't expose a pause-on-appear hook); track the fix in a future release rather than spending time on a local patch. + +## Lottie animation invisible with no error (`PLYLottieBridge` not exposed, iOS) + +**Symptom:** a Lottie animation block on a paywall renders as blank space on iOS — no crash, no SDK error log. + +**Known fix:** the Purchasely SDK does **not** link `lottie-ios` itself; it calls into the host app's Lottie rendering through a runtime bridge protocol, `PLYLottieBridge`. If the host app doesn't depend on `lottie-ios` and expose that bridge, the component silently renders nothing. This is expected behaviour, not an SDK bug — add the `lottie-ios` dependency and a `PLYLottieBridge` conformance to the app target that uses Lottie paywall blocks. + +## iOS 18.4/18.5 DEBUG builds silently bypass the image disk cache + +**Symptom:** on iOS 18.4/18.5, paywall images are re-downloaded on every appearance in **DEBUG** builds only; RELEASE builds cache normally. + +**Known fix:** an iOS 18.4/18.5 `URLSession` behaviour change makes DEBUG-configuration sessions ephemeral for certain cache configurations, silently bypassing the on-disk image cache. This is an OS-level quirk, not a Purchasely regression — confirm the symptom disappears in a RELEASE/TestFlight build before treating it as an app or SDK bug. + +## Purchase spinner stuck after cancelling (double purchase action on container + child label) + +**Symptom:** the user taps a purchase button, cancels the native purchase sheet, and the paywall's loading spinner never dismisses — the button appears permanently stuck. + +**Known fix:** Console misconfiguration, not an SDK bug — the `purchase` action was wired on **both** the button container and a child label/text element inside it. The tap fires two `purchase` actions; the SDK shows a loading spinner for the first and the second action's cancellation doesn't clear it (loader state is tracked per action instance, not per screen). Fix in the Screen Composer: remove the `purchase` action from the child label/text element and keep it only on the outer container that owns the loader. diff --git a/purchasely/skills/purchasely-debug/SKILL.md b/purchasely/skills/purchasely-debug/SKILL.md index b7b5369..510a4f0 100644 --- a/purchasely/skills/purchasely-debug/SKILL.md +++ b/purchasely/skills/purchasely-debug/SKILL.md @@ -27,7 +27,7 @@ The bundled references are intentionally curated, not a full copy of the public - `../../references/concepts/subscription-checks.md` — "user paid but premium gating doesn't unlock" bugs - `../../references/concepts/subscription-management.md` — "user cancelled but the app doesn't reflect it" (foreground resync) - `../../references/concepts/promotional-offers.md` — promo offer not applied / charged at regular price / `invalidOfferSignature` -- `../../references/concepts/campaigns.md` — trigger-based campaigns silently don't fire (`allowDeeplink` / `allowCampaigns` on v6; defaults `true` on native/Flutter/Cordova, React Native `allowDeeplink` default `false`; Android auto-intercepts) +- `../../references/concepts/campaigns.md` — trigger-based campaigns silently don't fire (`allowDeeplink` / `allowCampaigns` on v6; `allowDeeplink` defaults `true` on **every** v6 platform including React Native — the builder just omits the key when unset; `allowCampaigns` defaults `true` in v6 on iOS/Android/Flutter, was `false` in v5; Android auto-intercepts) - `../../references/concepts/lottie-animations.md` — blank/static Lottie blocks, missing native bridge/dependency, oversized animation JSON - `../../references/concepts/analytics-integration.md` — events fire but don't reach Firebase/Amplitude/AppsFlyer (or duplicate) - `../../references/architecture-patterns.md` — for projects using a wrapper class, diagnose wrapper-side issues (init order, decoupled Observer billing) @@ -58,13 +58,13 @@ Before patching code or declaring a root cause, run a Purchasely expert checkpoi If that subagent is not available, do the checkpoint inline using the `purchasely-sdk-expert` guidance when available, or this fallback checklist: -- Confirm the SDK generation: React Native uses v6 (`6.0.0-rc.2`); native iOS / native Android / Flutter use v6 (`6.0.0-rc.1`); Cordova uses v6 (`6.0.0-rc.1`, pulling native `6.0.0-rc.2`). +- Confirm the SDK generation: native iOS uses v6 (`6.0.0`, stable GA); native Android uses v6 (`6.0.1`, stable GA — Android never had a `6.0.0` tag, the line went rc.1 → rc.2 → rc.3 → `6.0.1`); Flutter uses v6 (`6.0.0`, pulling native iOS `6.0.0` + Android core `6.0.1`); React Native uses v6 (`6.0.0-rc.3`, npm `latest`); Cordova uses v6 (`6.0.0-rc.3`, npm dist-tag `next`, pulling native iOS/Android `6.0.0-rc.3`). - Confirm the suspected root cause matches the SDK logs, not just symptoms. - Confirm the fix uses current platform APIs and does not introduce removed v6 symbols or invented signatures. - Confirm running mode is explicit when Full purchase handling is expected. - Confirm presentation loading/display and dismissal use the correct API for the platform and rendering mode. - Confirm every interceptor branch resolves exactly once. -- Confirm Observer-mode purchases call `synchronize()` and use the right dismissal API. +- Confirm Observer-mode purchases rely on the automatic synchronization triggered by returning `SUCCESS` from the interceptor (no manual `synchronize()` call required inside it) and use the right dismissal API. - Confirm any Console-driven, campaign, BYOS, Lottie, or privacy claim was checked against the relevant reference. Incorporate corrections before editing files or reporting the diagnosis. @@ -134,7 +134,7 @@ The cause differs by platform: 1. **Check running mode** -- search for `runningMode`, `.full`, `.observer`/`PLYRunningMode.Observer`, or `PLYRunningMode`. ⚠️ **On native iOS/Android v6 the default changed from Full to Observer, silently.** If the init does NOT call `.runningMode(.full)` (iOS) / `runningMode(PLYRunningMode.Full)` (Android), the SDK is in Observer mode and will NOT process or validate purchases — this is the #1 "purchases stopped working after upgrading to v6" cause. (`PLYRunningMode.PaywallObserver` was also renamed to `PLYRunningMode.Observer`.) 2. **Full mode**: the SDK handles the purchase flow. Check that store products are correctly configured in the Console and that the store sandbox account is set up. On native v6, a Full-mode purchase with no store configured returns `PLYError.NoStoreConfigured`. -3. **Observer mode**: the app handles purchases itself. After a successful purchase, `Purchasely.synchronize()` must be called so the SDK validates the receipt. Native v6 Observer mode also does NOT auto-close the paywall (the implicit `close_all` is Full-only) — return `PLYInterceptResult.SUCCESS` to resolve the interceptor, then dismiss with `Purchasely.closeAllScreens()` from your billing-result handler (after the interceptor has resolved), unless a `close` / `close_all` action is configured on the button in the Console. +3. **Observer mode**: the app handles purchases itself. Returning `PLYInterceptResult.SUCCESS` from the `purchase`/`restore` interceptor already triggers Purchasely's synchronization automatically — a manual `Purchasely.synchronize()` call inside that interceptor is redundant, not required (it's still needed for purchases made **outside** the interceptor, e.g. a custom sell screen or BYOS). Native v6 Observer mode also does NOT auto-close the paywall (the implicit `close_all` is Full-only) — return `PLYInterceptResult.SUCCESS` to resolve the interceptor, then dismiss with `Purchasely.closeAllScreens()` from your billing-result handler (after the interceptor has resolved), unless a `close` / `close_all` action is configured on the button in the Console. 4. **Check store configuration** -- verify product IDs in Console match the store exactly (case-sensitive). Check that subscriptions/products are approved and available in sandbox. 5. **Check sandbox/test accounts** -- on iOS, verify a Sandbox Apple ID is signed in under Settings > App Store. On Android, verify the test account is in the license testers list. @@ -150,9 +150,9 @@ The cause differs by platform: ### Deeplinks Not Working 1. **Check handler method** -- all v6 SDKs (native iOS/Android, Flutter, React Native, Cordova): `handleDeeplink(...)`. The v5 `isDeeplinkHandled(...)` is **removed with no alias** on every v6 SDK — if v6 code still calls `isDeeplinkHandled`, that's the bug; rewrite it to `handleDeeplink`. -2. **Check the deeplink display flag** -- v6 renamed `readyToOpenDeeplink` → `allowDeeplink`. Native/Flutter/Cordova default **true** (so usually nothing to set; v6 displays deeplinks/campaigns immediately by default). **React Native v6 also uses `allowDeeplink`, but the default is `false`** — the app must call `.allowDeeplink(true)` on the `Purchasely.builder(...)` chain or deeplinks/campaigns won't display. If an app explicitly set `allowDeeplink(false)`, deeplinks won't display. +2. **Check the deeplink display flag** -- v6 renamed `readyToOpenDeeplink` → `allowDeeplink`. It defaults to **true** on **every** v6 platform, including React Native — there is no RN-specific exception; the RN builder simply omits the key when `.allowDeeplink(...)` isn't called and the native default applies (v6 displays deeplinks/campaigns immediately by default). If an app explicitly set `allowDeeplink(false)`, deeplinks won't display. 3. **Android v6 auto-interception** -- Android v6 reads the foreground activity intent automatically (zero code). **Pitfall:** a `singleTask`/`singleTop` activity that receives the deeplink in `onNewIntent` WITHOUT calling `setIntent(intent)` hides the URI — the SDK never sees it. Verify `setIntent(intent)` is called, or fall back to a manual `handleDeeplink(uri, activity)`. iOS does NOT auto-intercept — `handleDeeplink(url)` must be wired from AppDelegate/SceneDelegate. -4. **Check default presentation dismiss handler** -- native/Flutter: `setDefaultPresentationResultHandler`; **React Native v6**: `Purchasely.setDefaultPresentationDismissHandler((outcome) => …)` must be configured, or the SDK has nowhere to send deeplink/campaign paywall results (`outcome.presentation` is always populated, identifying the closed screen). +4. **Check default presentation dismiss handler** -- native iOS/Android v6, Flutter v6, and React Native v6 all use `Purchasely.setDefaultPresentationDismissHandler((outcome) => …)` (Android has always used this name — `setDefaultPresentationResultHandler` never existed there; iOS renamed to it in v6). It must be configured, or the SDK has nowhere to send deeplink/campaign paywall results (`outcome.presentation` is always populated, identifying the closed screen). Since Android `6.0.1`, the handler parameter is nullable — passing `null` unregisters it. 5. **Check URL scheme / universal links** -- verify the app's URL scheme or associated domains are correctly configured and match what the Console generates. 6. **Check timing** -- if `handleDeeplink` is called before `start()` completes, it will silently fail. For a cold-start deeplink, pass it on the init builder (`.handleDeeplink(url)` / `.handleDeeplink(intent.data)`). @@ -195,12 +195,12 @@ When you identify one of these patterns, apply the known fix immediately: | Events fire twice | Listener registered in `onResume`/`viewWillAppear` instead of `onCreate`/`viewDidLoad` | Move registration to a lifecycle method that runs only once, or guard with a flag | | User attributes not syncing | `setAttribute` called before `start()` completes | Move `setAttribute` calls into the `start()` completion handler or after it resolves | | Wrong paywall showing | Confusion between `placementId` and `presentationId`, or audience not matching | Use `placementId` for production flows (respects targeting); `presentationId` only for testing a specific screen | -| Purchase succeeds but status not updated | Observer mode without `synchronize()` call | Add `Purchasely.synchronize()` after every successful purchase in Observer mode. If using a wrapper pattern, ensure the wrapper calls `synchronize()` when observing `TransactionResult.Success` | +| Purchase succeeds but status not updated | Observer mode purchase not going through the interceptor at all (or the interceptor never returns `SUCCESS`) | Returning `SUCCESS`/`success` from the `purchase`/`restore` interceptor already triggers synchronization automatically — verify the interceptor is registered and actually resolves with `SUCCESS`. Only add a manual `Purchasely.synchronize()` call for purchases made **outside** the interceptor (a custom sell screen, BYOS) | | Observer purchase works but paywall freezes | The interceptor never signalled completion after the native purchase finished | Native v6: the `.purchase` handler must `return PLYInterceptResult.SUCCESS` (or `.FAILED`) for every outcome (success, cancel, error) -- a hung/unawaited billing call leaves it unsignalled. Flutter v6: the `PresentationActionKind.purchase` handler must `return InterceptResult.success` (or `.failed`) for every outcome. React Native v6: the `'purchase'` handler must `return 'success'` (or `'failed'`) for every outcome. Cordova v6: return or resolve `Purchasely.InterceptResult.success` (or `.failed`) for every outcome. In decoupled (reactive) architectures, make sure the billing result is mapped back to a returned result / completion for every branch | | Paywall loads but buttons do nothing | `PLYUIDelegate` / `UIDelegate` not set or not retained | Set the delegate and store a strong reference to the delegate object | | Crash on paywall display (Android) | Application context passed instead of Activity context | Pass the current Activity, not `applicationContext` | | App freezes after closing a flow paywall (touches don't register) | The X button fires `.close` (back navigation) instead of `.closeAll` (full exit); `PLYWindow` stays alive waiting for a next step that never comes | Fix the paywall in Purchasely Console: change X button action from `close` to `closeAll`. Fallback: map `.close` → `closeAllScreens()` in interceptor. See `../../references/troubleshooting/common-issues.md` §11 | -| Paywall doesn't dismiss after Observer-mode purchase | Observer mode does **not** auto-close (the implicit `close_all` is Full-only), so the app must dismiss itself | Native iOS/Android v6: inside the `.purchase` handler, `synchronize()` → `return PLYInterceptResult.SUCCESS`, then call `Purchasely.closeAllScreens()` from your billing-result handler **after** the interceptor has resolved (do not call it inside the interceptor closure before returning — that races the SDK). Flutter v6: `await Purchasely.synchronize()` → `return InterceptResult.success`, then dismiss with `presentation.close()`. React Native v6: `await Purchasely.synchronize()` → `return 'success'`, then dismiss with `request.close()`. Cordova v6: `Purchasely.synchronize(success, error)` → resolve `Purchasely.InterceptResult.success`, then dismiss with `closePresentation()` after the handler resolves. | +| Paywall doesn't dismiss after Observer-mode purchase | Observer mode does **not** auto-close (the implicit `close_all` is Full-only), so the app must dismiss itself | Native iOS/Android v6: inside the `.purchase` handler, `return PLYInterceptResult.SUCCESS` (auto-triggers synchronization), then call `Purchasely.closeAllScreens()` from your billing-result handler **after** the interceptor has resolved (do not call it inside the interceptor closure before returning — that races the SDK). Flutter v6: `return InterceptResult.success`, then dismiss with `presentation.close()`. React Native v6: `return 'success'`, then dismiss with `request.close()`. Cordova v6: resolve `Purchasely.InterceptResult.success`, then dismiss with `closePresentation()` after the handler resolves. | | Wrong screen reappears after Observer-mode purchase (e.g. the onboarding paywall replays) | The flow hosting the placement chains a post-purchase step to the wrong paywall on the Console | Inspect `flow_id` + `displayed_presentation` in the `PRESENTATION_LOADED` event after purchase. Dashboard → Flows → fix the post-purchase branch | | Chained follow-up placement shows the wrong/fallback screen | The follow-up `fetchPresentation` resolved against stale subscription state | iOS: await `synchronize()` via `withCheckedThrowingContinuation` BEFORE fetching the next placement. Android: fire-and-forget — accept brief stale-state risk | | Paywall not updating after Console changes | SDK presentation cache | Clear app data, force kill, or invalidate any app-side cache via an attribute change (iOS `PLYUserAttributeDelegate`) or an explicit `Purchasely.synchronize()` (Android) | @@ -216,9 +216,20 @@ When you identify one of these patterns, apply the known fix immediately: | Flutter (v6): Flow paywall opens but cannot be closed | Wrong display entry point or no programmatic dismiss wired | Display Flows via `PresentationBuilder.placement(id).build().display([Transition])` (or `preload()` then `display()`); the v6 request correctly owns the Flow window. Dismiss with `presentation.close()` and step back with `presentation.back()`. `presentPresentationForPlacement`/`fetchPresentation` no longer exist in Flutter v6 | | React Native (v6): Flow paywall opens but cannot be closed | Wrong display entry point or no programmatic dismiss wired | Display Flows via `Purchasely.presentation.placement(id).build().display(transition?)` (or `preload()` then `display()`); the v6 request correctly owns the Flow window. Dismiss with `request.close()` and step back with `request.back()`. `presentPresentationForPlacement`/`fetchPresentation` no longer exist in React Native v6 | | Lottie block is blank, static, or crashes while loading | Lottie is a weak dependency: the app is missing Airbnb Lottie, the iOS `PLYLottieBridge`, the Android `PLYLottieInterface` / `Purchasely.lottieView` registration, or the JSON is too large/unsupported | Add the native bridge and dependency from `../../references/concepts/lottie-animations.md`; keep JSON under 2 MB and validate it in LottieFiles Preview | -| Cordova v6: native crash on init or missing API | Plugin packages out of alignment or v5 syntax used with v6 packages | Pin all `@purchasely/cordova-plugin-*` packages to the same `6.0.0-rc.1` and use `Purchasely.start(options, success, error)`. See `../../references/sdk-versions.md` | -| Flutter (v6): native crash on init or missing API | Plugin packages out of alignment | Pin `purchasely_flutter`, `purchasely_google` and `purchasely_android_player` to the same `6.0.0-rc.1` (the v6 release pulling native iOS `Purchasely 6.0.0-rc.1` and Android `io.purchasely:core 6.0.0-rc.1`). See `../../references/sdk-versions.md` | -| React Native (v6): native crash on init or missing API | Plugin packages out of alignment (e.g. `react-native-purchasely 6.0.0-rc.2` + `@purchasely/react-native-purchasely-google 6.0.0-beta.12`) | Pin all `react-native-purchasely*` packages to the **exact** `6.0.0-rc.2` (the v6 release pulling native iOS `Purchasely 6.0.0-rc.2` and Android `io.purchasely:core 6.0.0-rc.2`). See `../../references/sdk-versions.md` | +| Cordova v6: native crash on init or missing API | Plugin packages out of alignment or v5 syntax used with v6 packages | Pin all `@purchasely/cordova-plugin-*` packages to the same `6.0.0-rc.3` and use `Purchasely.start(options, success, error)`. See `../../references/sdk-versions.md` | +| Flutter (v6): native crash on init or missing API | Plugin packages out of alignment | Pin `purchasely_flutter`, `purchasely_google` and `purchasely_android_player` to the same `6.0.0` (the stable GA release pulling native iOS `Purchasely 6.0.0` and Android `io.purchasely:core 6.0.1`). See `../../references/sdk-versions.md` | +| React Native (v6): native crash on init or missing API | Plugin packages out of alignment (e.g. `react-native-purchasely 6.0.0-rc.3` + `@purchasely/react-native-purchasely-google 6.0.0-beta.12`) | Pin all `react-native-purchasely*` packages to the **exact** `6.0.0-rc.3` (the v6 pre-release pulling native iOS `Purchasely 6.0.0-rc.3` and Android `io.purchasely:core 6.0.0-rc.3`). See `../../references/sdk-versions.md` | +| iOS: Lottie block invisible, no crash, no SDK error | The SDK never links `lottie-ios` — it resolves `NSClassFromString("PLYLottieBridge")` at runtime. If the host app doesn't depend on `lottie-ios` and expose that bridge class, nothing renders | Add the `lottie-ios` dependency and an `@objc(PLYLottieBridge)` conformance to the iOS host target. See `../../references/concepts/lottie-animations.md` | +| Purchase spinner stuck after cancelling | The `purchase` action is wired on **both** a container and a child label inside it — one tap fires two `PURCHASE_TAPPED` events; the SDK only restores the node that actually launched StoreKit | Screen Composer fix: keep the `purchase` action on a single element — the outer container that owns the loader — and remove it from the child label/text | +| Flutter: `await request.display(...)` never resolves | The `Future` resolves on the native `onDismissed` event, not on the MethodChannel call's own response. If the native side never emits that event, the `Future` hangs indefinitely with no timeout | Verify the native side dismisses and emits `onDismissed` for every exit path; add an app-side timeout/guard around `display()` if a hard upper bound is needed | +| Flutter: interceptor "never seems to run" | An exception thrown inside the `Purchasely.interceptAction(kind, handler)` callback is caught and swallowed by the bridge, which silently resolves `InterceptResult.failed` — no crash, no visible log | Wrap the handler body in `try/catch` and log inside it; don't rely on an uncaught exception surfacing on its own | +| Android: deeplink works on cold start but not in an already-running app | Activity is `singleTask`/`singleTop` and `onNewIntent` doesn't call `setIntent(intent)`, so the SDK's auto-interception reads the activity's stale intent | Call `setIntent(intent)` inside `onNewIntent`, or forward the URI manually with `Purchasely.handleDeeplink(uri, activity)` | +| Paywall not translated on Indonesian devices (builds < 6.0.1) | Android resolves Indonesian locales against the legacy qualifier `values-in`, but translations shipped under `values-id` | Upgrade to SDK **6.0.1**, which ships resources under the correct qualifier; don't patch app-side resources | +| `PRESENTATION_VIEWED` missing on high-volume Flows (builds < 6.0.0-rc.3) | An internal FIFO buffer tracking already-viewed presentations evicted entries once full (cap of 100), silently dropping the event for the earliest ones in a long session | Upgrade to **6.0.0-rc.3+**, which raised the cap to 200; don't build app-side dedup/backfill logic | +| iOS: app freezes on iPad after closing a campaign | Key-window restoration issue after the campaign's window was torn down; fixed in SDK **6.0.0** | Upgrade rather than adding app-side `makeKeyAndVisible()` workarounds, which don't reliably fix it | +| iOS: callbacks on a preloaded presentation never fire | The preloaded presentation was silently deallocated between preload and display when the app kept no strong reference to it (or to the request that produced it); fixed as of SDK **6.0.0-rc.2** | Keep a reference to the built request / loaded presentation regardless of the fix. See `../../references/concepts/presentation-cache.md` | +| iOS 18.4/18.5 DEBUG build: paywall images re-download on every appearance | An OS-level `URLSession` change makes DEBUG-configuration sessions ephemeral, silently bypassing the on-disk image cache — not a Purchasely regression | Confirm the symptom disappears in a RELEASE/TestFlight build before treating it as an app or SDK cache bug | +| Video `autoplay: false` ignored — video always plays | Open bug in SDK **6.0.0**; the video component has no pause-on-appear hook | No reliable app-side workaround yet; track the fix in a future release rather than patching locally | ## Step 5: Escalate to Purchasely Support (when the root cause is in the Screen / Console) diff --git a/purchasely/skills/purchasely-integrate/SKILL.md b/purchasely/skills/purchasely-integrate/SKILL.md index f1f7d1d..e9bfbc0 100644 --- a/purchasely/skills/purchasely-integrate/SKILL.md +++ b/purchasely/skills/purchasely-integrate/SKILL.md @@ -56,12 +56,12 @@ Before writing integration code, run a Purchasely expert checkpoint. If the harn If that subagent is not available, do the checkpoint inline using the `purchasely-sdk-expert` guidance when available, or this fallback checklist: -- Confirm the SDK generation: React Native uses v6 (`6.0.0-rc.2`); native iOS / native Android / Flutter / Cordova use v6 (`6.0.0-rc.1`). +- Confirm the SDK generation: native iOS uses v6 (`6.0.0`, stable GA); native Android uses v6 (`6.0.1`, stable GA — Android never had a `6.0.0` tag); Flutter uses v6 (`6.0.0`, pulling native iOS `6.0.0` + Android core `6.0.1`); React Native uses v6 (`6.0.0-rc.3`, npm `latest`); Cordova uses v6 (`6.0.0-rc.3`, npm dist-tag `next`). - Confirm versions are pinned from `../../references/sdk-versions.md` and no floating ranges are introduced. - Confirm Full mode is explicit when Purchasely must process and validate purchases. - Confirm the presentation path matches the platform generation and handles `DEACTIVATED` / `FALLBACK` where relevant. - Confirm every interceptor branch resolves exactly once (`PLYInterceptResult`, `InterceptResult`, or `onProcessAction`). -- Confirm Observer-mode purchases call `synchronize()` and use the right dismissal API for the platform. +- Confirm Observer-mode purchases rely on the automatic synchronization triggered by returning `SUCCESS` from the interceptor (no manual `synchronize()` call inside it) and use the right dismissal API for the platform. - Confirm user identity and audience attributes are set before audience-dependent presentation loading. - Confirm any exact API signature was checked in the matching platform reference. @@ -99,13 +99,13 @@ Run the appropriate installation commands and modify project files as needed. | Platform | Latest stable version | |----------|-----------------------| -| iOS (native) | **6.0.0-rc.1** | -| Android (native) | **6.0.0-rc.1** | -| Flutter | **6.0.0-rc.1** | -| React Native | **6.0.0-rc.2** | -| Cordova | **6.0.0-rc.1** | +| iOS (native) | **6.0.0** (stable GA) | +| Android (native) | **6.0.1** (stable GA — Android never had a `6.0.0` tag; the line went rc.1 → rc.2 → rc.3 → `6.0.1`) | +| Flutter | **6.0.0** (stable, pulls native iOS `6.0.0` + Android core `6.0.1`) | +| React Native | **6.0.0-rc.3** (npm `latest` tag; GA `6.0.0` in preparation) | +| Cordova | **6.0.0-rc.3** (npm dist-tag `next` — `latest` is still `5.7.3`; install the version explicitly) | -Always pin to the **exact** version above, never floating (`5.+`, `6.+`, `^5.0.0`, `^6.0.0-rc.2`). Floating versions break reproducibility and silently pull regressions. Because these v6 versions are pre-releases, they must be pinned exactly on every layer (no caret / range). +**Pin exactly on Android, Flutter, React Native, and Cordova** — never floating (`5.+`, `6.+`, `^5.0.0`, `^6.0.0-rc.3`). Floating versions break reproducibility and silently pull regressions; React Native and Cordova are still pinned to a pre-release (`6.0.0-rc.3`), and a caret/range won't resolve a pre-release at all. **iOS is the exception**: it's stable GA, so a minor-range pin is the recommended form — SPM `from: "6.0.0"` (primary) or CocoaPods `pod 'Purchasely', '~> 6.0'` — see the iOS install section below. **Before installing, ask the user these questions (adapt per platform):** @@ -117,32 +117,32 @@ For **iOS** — no extra questions needed (App Store is the only store, video is ### iOS -Requirements: iOS 11.0+, Xcode 13.0+, Swift 5.0+ (Swift 6 strict concurrency supported) +Requirements: iOS 13.4+ (unchanged from 5.x — not a v6-specific bump), Xcode 13.0+, Swift 5.0+ (Swift 6 strict concurrency supported) -**Option A — CocoaPods** (preferred if a `Podfile` exists): +**Option A — Swift Package Manager** (primary / preferred): + +In Xcode: File > Add Packages, then enter the repository URL: +``` +https://github.com/Purchasely/Purchasely-iOS +``` +Select **Up to Next Major Version, starting at `6.0.0`** (Package.swift: `.package(url: "https://github.com/Purchasely/Purchasely-iOS", from: "6.0.0")`). + +**Option B — CocoaPods** (if a `Podfile` exists): Add to the app's `Podfile`: ```ruby -pod 'Purchasely', '6.0.0-rc.1' +pod 'Purchasely', '~> 6.0' ``` Then run: ```bash pod install --repo-update ``` -**Option B — Swift Package Manager** (if the project uses SPM): - -In Xcode: File > Add Packages, then enter the repository URL: -``` -https://github.com/Purchasely/Purchasely-iOS -``` -Select **Exact Version 6.0.0-rc.1** (Package.swift: `.package(url: "https://github.com/Purchasely/Purchasely-iOS", exact: "6.0.0-rc.1")`). - **Option C — Carthage**: Add to `Cartfile`: ``` -binary "https://raw.githubusercontent.com/Purchasely/Purchasely-iOS/master/Purchasely.json" == 6.0.0-rc.1 +binary "https://raw.githubusercontent.com/Purchasely/Purchasely-iOS/master/Purchasely.json" ~> 6.0 ``` Then run: ```bash @@ -153,31 +153,31 @@ carthage update ### Android -Requirements: minSdk 23, compileSdk 36, Kotlin 2.2.x, Gradle 9.x, JDK 17 (to build) +Requirements: minSdk 23, compileSdk 36, Kotlin 2.3.x, Gradle 9.x, JDK 17 (to build) The Purchasely SDK is published on **Maven Central** — no custom repository needed. Just make sure `mavenCentral()` is present in your `settings.gradle.kts` (it is by default in modern projects). -**Add dependencies** in `app/build.gradle.kts` (pin to exact `6.0.0-rc.1` — see `../../references/sdk-versions.md`): +**Add dependencies** in `app/build.gradle.kts` (pin to exact `6.0.1` — see `../../references/sdk-versions.md`): ```kotlin dependencies { // Core SDK — Required - implementation("io.purchasely:core:6.0.0-rc.1") + implementation("io.purchasely:core:6.0.1") // Google Play Store — Required if publishing on Google Play - implementation("io.purchasely:google-play:6.0.0-rc.1") + implementation("io.purchasely:google-play:6.0.1") // Video Player — Optional, for video support in Screens - implementation("io.purchasely:player:6.0.0-rc.1") + implementation("io.purchasely:player:6.0.1") } ``` **Alternative stores** (instead of or in addition to Google Play): ```kotlin // Huawei AppGallery (also requires Huawei AGConnect plugin and repo) -implementation("io.purchasely:huawei-services:6.0.0-rc.1") +implementation("io.purchasely:huawei-services:6.0.1") // Amazon Appstore -implementation("io.purchasely:amazon:6.0.0-rc.1") +implementation("io.purchasely:amazon:6.0.1") ``` For **Huawei**, also add to the project-level build.gradle: @@ -204,7 +204,7 @@ Then sync Gradle. Requirements: iOS 13.4+, Android minSdkVersion 23, compileSdk 35 (align compileSdk/targetSdk on the existing app, 35+). Built and tested against React Native 0.86 / Node 22. -> **React Native is on the v6 API** (same generation as native iOS / Android / Flutter — no longer grouped with Cordova). `react-native-purchasely 6.0.0-rc.2` pulls the 6.0.0-rc.2 native SDKs (iOS pod `Purchasely`, Android `io.purchasely:core`) and exposes the v6 TypeScript surface: `Purchasely.builder('key')…start()`, `Purchasely.presentation.placement(id).build()` → a `PLYPresentationRequest` (`preload()` / `display(transition?)`), and the per-action `Purchasely.interceptAction(kind, handler)` returning `'success' | 'failed' | 'notHandled'` strings. `../../references/react-native/integration.md` documents the v6 API. +> **React Native is on the v6 API** (same generation as native iOS / Android / Flutter — no longer grouped with Cordova). `react-native-purchasely 6.0.0-rc.3` pulls the 6.0.0-rc.3 native SDKs (iOS pod `Purchasely`, Android `io.purchasely:core`) and exposes the v6 TypeScript surface: `Purchasely.builder('key')…start()`, `Purchasely.presentation.placement(id).build()` → a `PLYPresentationRequest` (`preload()` / `display(transition?)`), and the per-action `Purchasely.interceptAction(kind, handler)` returning `'success' | 'failed' | 'notHandled'` strings. `../../references/react-native/integration.md` documents the v6 API. **1. Install the core SDK:** ```bash @@ -248,37 +248,37 @@ allprojects { } ``` -**CRITICAL: All Purchasely packages must be at the exact same version.** Pin to the **exact** `6.0.0-rc.2` — never a caret / range, because it is a pre-release (see `../../references/sdk-versions.md`): +**CRITICAL: All Purchasely packages must be at the exact same version.** Pin to the **exact** `6.0.0-rc.3` — never a caret / range, because it is a pre-release (see `../../references/sdk-versions.md`): ```json "dependencies": { - "react-native-purchasely": "6.0.0-rc.2", - "@purchasely/react-native-purchasely-google": "6.0.0-rc.2", - "@purchasely/react-native-purchasely-android-player": "6.0.0-rc.2" + "react-native-purchasely": "6.0.0-rc.3", + "@purchasely/react-native-purchasely-google": "6.0.0-rc.3", + "@purchasely/react-native-purchasely-android-player": "6.0.0-rc.3" } ``` -> **Native dependency.** `react-native-purchasely 6.0.0-rc.2` pins the 6.0.0-rc.2 native SDKs — Android `io.purchasely:core` / `google-play` / `player` on **Maven Central**, iOS `Purchasely` on the **CocoaPods trunk** — so the project builds from the public repositories. +> **Native dependency.** `react-native-purchasely 6.0.0-rc.3` pins the 6.0.0-rc.3 native SDKs — Android `io.purchasely:core` / `google-play` / `player` on **Maven Central**, iOS `Purchasely` on the **CocoaPods trunk** — so the project builds from the public repositories. ### Flutter Requirements: iOS 13.4+, Android minSdk 23, compileSdk 36, targetSdk 35 -> **Flutter is on the v6 API** (same generation as native iOS / Android — no longer grouped with React Native / Cordova). `purchasely_flutter 6.0.0-rc.1` pulls the 6.0.0-rc.1 native SDKs (iOS `Purchasely`, Android `io.purchasely:core`) and exposes the v6 Dart surface: `PurchaselyBuilder.apiKey(...).start()`, `PresentationBuilder` → `PresentationRequest` (`preload()` / `display([Transition])`), and the per-action `Purchasely.interceptAction(kind, handler)` returning an `InterceptResult`. `../../references/flutter/integration.md` and `../../references/flutter/migration-v6.md` document the v6 API. +> **Flutter is on the v6 API** (same generation as native iOS / Android — no longer grouped with React Native / Cordova). `purchasely_flutter 6.0.0` is stable GA and pulls native iOS `Purchasely 6.0.0` + Android `io.purchasely:core 6.0.1` (Android never had a `6.0.0` native tag — its GA is `6.0.1`) and exposes the v6 Dart surface: `PurchaselyBuilder.apiKey(...).start()`, `PresentationBuilder` → `PresentationRequest` (`preload()` / `display([Transition])`), and the per-action `Purchasely.interceptAction(kind, handler)` returning an `InterceptResult`. `../../references/flutter/integration.md` and `../../references/flutter/migration-v6.md` document the v6 API. **1. Install the core SDK:** ```bash -flutter pub add purchasely_flutter:6.0.0-rc.1 +flutter pub add purchasely_flutter:6.0.0 ``` **2. Install the store dependency (required for Android):** ```bash # Google Play — required if targeting Google Play Store -flutter pub add purchasely_google:6.0.0-rc.1 +flutter pub add purchasely_google:6.0.0 ``` **3. Optional — video player for Android:** ```bash -flutter pub add purchasely_android_player:6.0.0-rc.1 +flutter pub add purchasely_android_player:6.0.0 ``` **4. iOS pods:** @@ -302,31 +302,33 @@ allprojects { } ``` -**CRITICAL: All Purchasely packages must be at the exact same version.** Pin to `6.0.0-rc.1` (see `../../references/sdk-versions.md`): +**CRITICAL: All Purchasely packages must be at the exact same version.** Pin to `6.0.0` (see `../../references/sdk-versions.md`): ```yaml dependencies: - purchasely_flutter: 6.0.0-rc.1 - purchasely_google: 6.0.0-rc.1 - purchasely_android_player: 6.0.0-rc.1 + purchasely_flutter: 6.0.0 + purchasely_google: 6.0.0 + purchasely_android_player: 6.0.0 ``` -> **Native dependency.** `purchasely_flutter 6.0.0-rc.1` pins the 6.0.0-rc.1 native SDKs — Android `io.purchasely:core` / `google-play` / `player` on **Maven Central**, iOS `Purchasely` on the **CocoaPods trunk** — so the project builds from the public repositories with no `mavenLocal()` and no development pod. +> **Native dependency.** `purchasely_flutter 6.0.0` pins native iOS `Purchasely 6.0.0` (CocoaPods trunk) and Android `io.purchasely:core 6.0.1` / `google-play` / `player` (Maven Central) — so the project builds from the public repositories with no `mavenLocal()` and no development pod. ### Cordova Requirements: iOS 13.4+, Android minSdk 23, compileSdk 36, targetSdk 35 > **Cordova is on the v6 API too** (same generation as native iOS / Android / Flutter / React Native). Unlike the builder-based v6 plugins, the **Cordova JavaScript surface stays method-based**: the native bridges were rewired to the 6.0 native SDKs behind the same `cordova.exec` actions. Most methods keep their v5 names and signatures (`fetchPresentation` / `presentPresentation[ForPlacement]`, `closePresentation()`, `userLogin` / `userLogout`, every `setUserAttributeWith*`), but there are **three breaking surfaces**: `Purchasely.start(options, success, error)` now takes a **single options object** (the v5 positional list is gone); the action interceptor is **per-action** `interceptAction(kind, handler)` returning a `Purchasely.InterceptResult` (`setPaywallActionInterceptor` + `onProcessAction` were removed; `PaywallAction` renamed `PresentationAction`); and the `isFullscreen` boolean became a **display mode** (`TransitionType`). Other v6 changes: default running mode is now **Observer** (set `runningMode: Purchasely.RunningMode.full` for purchase handling), deeplinks renamed `allowDeeplink` / `handleDeeplink` (+ new `allowCampaigns`), dismiss handler renamed `setDefaultPresentationDismissHandler`, `synchronize(success, error)` now reports completion, and `presentSubscriptions()` / `presentProductWithIdentifier()` / `presentPlanWithIdentifier()` / `showPresentation()` / `hidePresentation()` were **removed**. `../../references/cordova/integration.md` and `../../references/cordova/migration-v6.md` document the v6 API. +> +> **Version note.** Cordova's v6 plugin is pinned to `6.0.0-rc.3`, pulling native iOS/Android `6.0.0-rc.3` — still a pre-release. The npm `latest` dist-tag still points to `5.7.3`; the v6 pre-release is published under the `next` dist-tag, so it must be installed with an **explicit version**, not `@latest` or `@next` (which can move). **1. Install the core plugin:** ```bash -cordova plugin add @purchasely/cordova-plugin-purchasely@6.0.0-rc.1 +cordova plugin add @purchasely/cordova-plugin-purchasely@6.0.0-rc.3 ``` **2. Install the store dependency (required for Android):** ```bash # Google Play — required if targeting Google Play Store -cordova plugin add @purchasely/cordova-plugin-purchasely-google@6.0.0-rc.1 +cordova plugin add @purchasely/cordova-plugin-purchasely-google@6.0.0-rc.3 ``` **3. Android setup** — edit `android/build.gradle`: @@ -345,11 +347,11 @@ allprojects { } ``` -**CRITICAL: All Purchasely packages must be at the exact same version.** Pin to `6.0.0-rc.1` (see `../../references/sdk-versions.md`): +**CRITICAL: All Purchasely packages must be at the exact same version.** Pin to `6.0.0-rc.3` (see `../../references/sdk-versions.md`): ```json "dependencies": { - "@purchasely/cordova-plugin-purchasely": "6.0.0-rc.1", - "@purchasely/cordova-plugin-purchasely-google": "6.0.0-rc.1" + "@purchasely/cordova-plugin-purchasely": "6.0.0-rc.3", + "@purchasely/cordova-plugin-purchasely-google": "6.0.0-rc.3" } ``` @@ -441,8 +443,8 @@ const started = await Purchasely.builder('YOUR_API_KEY') .logLevel('debug') // 'debug' | 'info' | 'warn' | 'error' .stores(['google']) // Android: 'google' | 'huawei' | 'amazon' .storekitVersion('storeKit2') // iOS: 'storeKit2' (recommended) | 'storeKit1' - .allowDeeplink(true) // allow the SDK to open deeplinks (default false on RN) - .allowCampaigns(true) // optional campaign display gate (default true) + .allowDeeplink(true) // allow the SDK to open deeplinks (default true — omitting the call keeps the native default) + .allowCampaigns(true) // optional campaign display gate (default true in v6; was false in v5) .start(); // Promise console.log('Purchasely started:', started); ``` @@ -806,7 +808,7 @@ Purchasely.interceptAction(PLYPresentationAction.Purchase::class.java) { _, acti } ``` -Android v6 returns `PLYInterceptResult.SUCCESS`, `FAILED`, or `NOT_HANDLED`; there is no `processAction` callback in the new native Android interceptor. +Android v6 returns `PLYInterceptResult.SUCCESS`, `FAILED`, or `NOT_HANDLED`; there is no `processAction` callback in the new native Android interceptor. `interceptAction` / `removeActionInterceptor` are member functions of `Purchasely` since rc.3 — no `import io.purchasely.ext.interceptAction` is needed (that was only required pre-rc.3, when they were top-level extension functions); a leftover import is harmless dead code. ### Android (Java, SDK v6) @@ -973,7 +975,7 @@ A user can lose access to their subscription on a new device, after a reinstall, > ⚠️ **Check the Purchasely paywall first.** Most Purchasely Screens built with the Screen Composer already include a "Restore" button — the Console operator can toggle it on. If it's already there on every relevant paywall, **do not add a duplicate** in app code; clarify the situation with the customer / Console operator and surface the existing button. Only add an app-side restore button when (a) the Purchasely Screen does **not** have one, **and** (b) you need a Restore action outside the paywall (e.g. in app Settings — recommended for Apple review). -If you do add it, wire it to `Purchasely.restoreAllProducts(...)`. See `../../references/concepts/subscription-checks.md` for the full per-platform code samples and the Observer-mode variant (intercept the `RESTORE` paywall action, run your own restore, call `Purchasely.synchronize()` + `proceed(true)`). +If you do add it, wire it to `Purchasely.restoreAllProducts(...)`. See `../../references/concepts/subscription-checks.md` for the full per-platform code samples and the Observer-mode variant (intercept the `RESTORE` paywall action, run your own restore, then return `success` — this triggers synchronization automatically, no manual `Purchasely.synchronize()` call needed). **Action:** Search the project for a Settings screen / Account screen. If one exists and the Purchasely paywalls don't already provide restore, add a "Restore Purchases" button there with the SDK call. If the Purchasely paywalls do provide restore, leave the in-paywall button as the canonical entry point and tell the user. @@ -1031,7 +1033,7 @@ See `../../references/architecture-patterns.md` for detailed architecture diagra ## Important Notes - In **Full mode**, the SDK handles the entire purchase flow. You do not need to call StoreKit/Play Billing APIs yourself. -- In **Observer mode**, you handle purchases yourself and must call `Purchasely.synchronize()` after each successful purchase so Purchasely can track it. +- In **Observer mode**, you handle purchases yourself; returning `SUCCESS` from the `purchase`/`restore` interceptor already triggers synchronization automatically, so Purchasely can track it — call `Purchasely.synchronize()` manually only for purchases made outside the interceptor (a custom sell screen, BYOS). - Always test with a sandbox/test account before going to production. - Switch `logLevel` to `ERROR` (or remove the parameter) before releasing to production. - The SDK supports multiple stores on Android (Google, Huawei, Amazon). Only include the stores your app actually publishes on. @@ -1046,10 +1048,12 @@ See `../../references/architecture-patterns.md` for detailed architecture diagra When the interceptor receives a `PURCHASE` action in Observer mode, you run the native billing flow yourself. After it succeeds: -- **Native iOS/Android (v6):** intercept the `.purchase` action, run your billing flow, call **`Purchasely.synchronize()`** to upload the receipt, then **return `PLYInterceptResult.SUCCESS`** (`.success` on iOS) from the interceptor. (There is no `proceed`/`processAction` callback in the v6 native interceptor; returning `.success` is the v6 equivalent of the old `processAction(false)`.) Because Observer mode does not auto-close, **dismiss the paywall yourself with `Purchasely.closeAllScreens()` after the interceptor has resolved** (from your billing-result handler) — unless you wire a `close` / `close_all` action on the button in the Console. Do **not** call `closeAllScreens()` inside the interceptor closure before returning the result — that races the SDK. -- **Flutter (v6):** intercept the `.purchase` action with `Purchasely.interceptAction(PresentationActionKind.purchase, ...)`, run your billing flow, `await Purchasely.synchronize()` to upload the receipt, then **return `InterceptResult.success`** from the handler. There is no `onProcessAction`; returning `.success` is the v6 equivalent of `processAction(false)`. Observer mode does not auto-close, so dismiss the paywall yourself with `presentation.close()` on the loaded `Presentation` **after** the handler resolves. -- **React Native (v6):** intercept the `'purchase'` action with `Purchasely.interceptAction('purchase', ...)`, run your billing flow, `await Purchasely.synchronize()` to upload the receipt, then **return `'success'`** from the handler. There is no `onProcessAction`; returning `'success'` is the v6 equivalent of `processAction(false)`. Observer mode does not auto-close, so dismiss the paywall yourself with `request.close()` on the held `PresentationRequest` **after** the handler resolves. -- **Cordova (v6):** intercept the `purchase` action with `Purchasely.interceptAction(Purchasely.PresentationAction.purchase, handler)`, run your billing flow, call `Purchasely.synchronize(success, error)` to upload the receipt, then **return (or resolve to) `Purchasely.InterceptResult.success`** from the handler. There is no `onProcessAction`; returning `.success` tells the SDK you handled the action. Observer mode does not auto-close, so dismiss with `Purchasely.closePresentation()` after the handler resolves. +> ⚠️ **Returning `SUCCESS` already triggers synchronization automatically.** Since the SDK synchronizes on a successful `purchase`/`restore` interceptor result, do **not** call `Purchasely.synchronize()` manually inside the interceptor — it's redundant (harmless, but unnecessary extra work). Manual `synchronize()` is only needed for purchases made **outside** the interceptor path entirely (a custom sell screen, BYOS). + +- **Native iOS/Android (v6):** intercept the `.purchase` action, run your billing flow, then **return `PLYInterceptResult.SUCCESS`** (`.success` on iOS) from the interceptor. (There is no `proceed`/`processAction` callback in the v6 native interceptor; returning `.success` is the v6 equivalent of the old `processAction(false)`.) Because Observer mode does not auto-close, **dismiss the paywall yourself with `Purchasely.closeAllScreens()` after the interceptor has resolved** (from your billing-result handler) — unless you wire a `close` / `close_all` action on the button in the Console. Do **not** call `closeAllScreens()` inside the interceptor closure before returning the result — that races the SDK. +- **Flutter (v6):** intercept the `.purchase` action with `Purchasely.interceptAction(PresentationActionKind.purchase, ...)`, run your billing flow, then **return `InterceptResult.success`** from the handler. There is no `onProcessAction`; returning `.success` is the v6 equivalent of `processAction(false)`. Observer mode does not auto-close, so dismiss the paywall yourself with `presentation.close()` on the loaded `Presentation` **after** the handler resolves. +- **React Native (v6):** intercept the `'purchase'` action with `Purchasely.interceptAction('purchase', ...)`, run your billing flow, then **return `'success'`** from the handler. There is no `onProcessAction`; returning `'success'` is the v6 equivalent of `processAction(false)`. Observer mode does not auto-close, so dismiss the paywall yourself with `request.close()` on the held `PresentationRequest` **after** the handler resolves. +- **Cordova (v6):** intercept the `purchase` action with `Purchasely.interceptAction(Purchasely.PresentationAction.purchase, handler)`, run your billing flow, then **return (or resolve to) `Purchasely.InterceptResult.success`** from the handler. There is no `onProcessAction`; returning `.success` tells the SDK you handled the action. Observer mode does not auto-close, so dismiss with `Purchasely.closePresentation()` after the handler resolves. **The order matters:** the SDK must learn the action was handled BEFORE the paywall tears down; reversing it leaves the paywall in an inconsistent state. On native v6, return the result first, then dismiss with `closeAllScreens()` from your billing-result handler — don't call it inside the interceptor closure. @@ -1057,11 +1061,11 @@ When the interceptor receives a `PURCHASE` action in Observer mode, you run the | Platform | Minimum version | |----------|-----------------| -| iOS (native) | **6.0.0-rc.1** — return `.success`, then call `Purchasely.closeAllScreens()` after the interceptor resolves (Observer mode does not auto-close; or wire a Console `close` action). It is `@MainActor`-isolated. Wrap in `Task { @MainActor in Purchasely.closeAllScreens() }` when called from a non-isolated synchronous context. | -| Android (native) | **6.0.0-rc.1** — return `PLYInterceptResult.SUCCESS`, then call `Purchasely.closeAllScreens()` after the interceptor resolves (Observer mode does not auto-close; or wire a Console `close` action). No threading constraint. | -| Flutter | **6.0.0-rc.1** — return `InterceptResult.success`, then dismiss with `presentation.close()` on the loaded `Presentation` after the handler resolves (Observer mode does not auto-close; or wire a Console `close` action). There is no `closePresentation()` in Flutter v6. | -| React Native | **6.0.0-rc.2** — return `'success'`, then dismiss with `request.close()` on the held `PresentationRequest` after the handler resolves (Observer mode does not auto-close; or wire a Console `close` action). There is no `closePresentation()` / `closeAllScreens()` in React Native v6. | -| Cordova | **6.0.0-rc.1** — return `Purchasely.InterceptResult.success`, then dismiss with `Purchasely.closePresentation()` in the public JS bridge after the handler resolves (method-based; no `closeAllScreens()` on the JS side). | +| iOS (native) | **6.0.0** — return `.success`, then call `Purchasely.closeAllScreens()` after the interceptor resolves (Observer mode does not auto-close; or wire a Console `close` action). It is `@MainActor`-isolated. Wrap in `Task { @MainActor in Purchasely.closeAllScreens() }` when called from a non-isolated synchronous context. | +| Android (native) | **6.0.1** — return `PLYInterceptResult.SUCCESS`, then call `Purchasely.closeAllScreens()` after the interceptor resolves (Observer mode does not auto-close; or wire a Console `close` action). No threading constraint. | +| Flutter | **6.0.0** — return `InterceptResult.success`, then dismiss with `presentation.close()` on the loaded `Presentation` after the handler resolves (Observer mode does not auto-close; or wire a Console `close` action). There is no `closePresentation()` in Flutter v6. | +| React Native | **6.0.0-rc.3** — return `'success'`, then dismiss with `request.close()` on the held `PresentationRequest` after the handler resolves (Observer mode does not auto-close; or wire a Console `close` action). There is no `closePresentation()` / `closeAllScreens()` in React Native v6. | +| Cordova | **6.0.0-rc.3** — return `Purchasely.InterceptResult.success`, then dismiss with `Purchasely.closePresentation()` in the public JS bridge after the handler resolves (method-based; no `closeAllScreens()` on the JS side). | Full version list: `../../references/sdk-versions.md`. @@ -1069,53 +1073,41 @@ Full version list: `../../references/sdk-versions.md`. ### iOS Observer-mode post-purchase (v6) -In v6 the `.purchase` interceptor returns a `PLYInterceptResult`. Run your billing flow, `synchronize()`, then return `.success`. Observer mode does not auto-close, so dismiss the paywall with `Purchasely.closeAllScreens()` **after** the interceptor returns — or wire a `close` action in the Console: +In v6 the `.purchase` interceptor returns a `PLYInterceptResult`. Run your billing flow, then return `.success` — this already triggers synchronization automatically, so do **not** call `Purchasely.synchronize()` yourself here. Observer mode does not auto-close, so dismiss the paywall with `Purchasely.closeAllScreens()` **after** the interceptor returns — or wire a `close` action in the Console: ```swift Purchasely.interceptAction(.purchase) { info, params in let bought = await self.runMyBillingFlow() guard bought else { return .failed } - await self.synchronizeReceipt() // upload receipt; await if you chain a follow-up placement // Observer mode does not auto-close. Schedule the dismissal to run AFTER this interceptor // closure has returned its result — calling closeAllScreens() inline (before the return) // races the SDK. Skip this entirely if a `close` action is configured on the button in the Console. Task { @MainActor in Purchasely.closeAllScreens() } - return .success // v6 equivalent of processAction(false) -} - -@MainActor -private func synchronizeReceipt() async { - await withCheckedContinuation { (cont: CheckedContinuation) in - Purchasely.synchronize( - success: { cont.resume() }, - failure: { _ in cont.resume() } - ) - } + return .success // v6 equivalent of processAction(false); auto-triggers synchronization } ``` +> If you chain a subscriber-gated follow-up placement right after dismissal and need the freshest subscription state before fetching it, you may still explicitly `await` `Purchasely.synchronize()` from your billing-result / dismissal handler (i.e. after the interceptor has already returned) — see "Chain a Follow-up Placement" below. Calling it there is not redundant with the automatic sync; calling it *inside* the interceptor before returning `.success` is. + ### Android Observer-mode post-purchase (v6) ```kotlin Purchasely.interceptAction { _, purchase -> val bought = runMyBillingFlow(purchase.plan) if (!bought) return@interceptAction PLYInterceptResult.FAILED - // synchronize() refreshes the subscriptions cache. Observer mode does not auto-close, so - // dismiss from onSuccess — which runs after this interceptor has resolved. Skip closeAllScreens() - // if a `close` action is configured on the button in the Console. - Purchasely.synchronize( - onSuccess = { Purchasely.closeAllScreens() }, - onError = { /* surface failure */ } - ) - PLYInterceptResult.SUCCESS // v6 equivalent of processAction(false); resolve, then dismiss above + // Observer mode does not auto-close. Post the dismissal to run AFTER this interceptor + // has resolved — do not call closeAllScreens() before the return. Skip this entirely if a + // `close` action is configured on the button in the Console. + Handler(Looper.getMainLooper()).post { Purchasely.closeAllScreens() } + PLYInterceptResult.SUCCESS // v6 equivalent of processAction(false); auto-triggers synchronization } ``` -> On Android, `Purchasely.synchronize(onSuccess = { plan -> }, onError = { error -> })` accepts optional callbacks and refreshes the subscriptions cache before `onSuccess`. To bridge a blocking billing flow inside a suspend interceptor, use `suspendCancellableCoroutine { ... }`. +> Returning `SUCCESS` already triggers synchronization automatically — do not also call `Purchasely.synchronize()` here. `Purchasely.synchronize(onSuccess = { plan -> }, onError = { error -> })` remains available for purchases made **outside** the interceptor (custom sell screen, BYOS) or to force a resync elsewhere in the app. To bridge a blocking billing flow inside a suspend interceptor, use `suspendCancellableCoroutine { ... }`. ### Flutter Observer-mode post-purchase (v6) -In v6 the `.purchase` interceptor returns an `InterceptResult`. Handle the `purchase` action with `Purchasely.interceptAction(...)`, run your own billing flow, `await Purchasely.synchronize()` (now awaitable — it resolves on completion and throws a `PlatformException` on failure), return `InterceptResult.success`, then dismiss the loaded `Presentation` with `presentation.close()` after the handler resolves (Observer mode does not auto-close): +In v6 the `.purchase` interceptor returns an `InterceptResult`. Handle the `purchase` action with `Purchasely.interceptAction(...)`, run your own billing flow, then return `InterceptResult.success` — this already triggers synchronization automatically, so do **not** also call `Purchasely.synchronize()` here — then dismiss the loaded `Presentation` with `presentation.close()` after the handler resolves (Observer mode does not auto-close): ```dart await Purchasely.interceptAction( @@ -1124,13 +1116,7 @@ await Purchasely.interceptAction( if (payload is! PurchasePayload) return InterceptResult.notHandled; final ok = await MyPurchaseSystem.purchase(payload.plan.vendorId); if (!ok) return InterceptResult.failed; - try { - await Purchasely.synchronize(); // upload the receipt to Purchasely - } on PlatformException { - // surface failure - return InterceptResult.failed; - } - return InterceptResult.success; // v6 equivalent of processAction(false) + return InterceptResult.success; // v6 equivalent of processAction(false); auto-triggers synchronization }, ); @@ -1143,20 +1129,14 @@ Future onPurchaseSuccess(Presentation presentation) async { ### React Native Observer-mode post-purchase (v6) -In v6 the `'purchase'` interceptor returns a **string** result. Handle the `'purchase'` action with `Purchasely.interceptAction(...)`, run your own billing flow, `await Purchasely.synchronize()` (now awaitable — it resolves on completion and rejects on failure), return `'success'`, then dismiss the held `PresentationRequest` with `request.close()` after the handler resolves (Observer mode does not auto-close): +In v6 the `'purchase'` interceptor returns a **string** result. Handle the `'purchase'` action with `Purchasely.interceptAction(...)`, run your own billing flow, then return `'success'` — this already triggers synchronization automatically, so do **not** also call `Purchasely.synchronize()` here — then dismiss the held `PresentationRequest` with `request.close()` after the handler resolves (Observer mode does not auto-close): ```ts Purchasely.interceptAction('purchase', async (info, payload) => { if (payload?.kind !== 'purchase') return 'notHandled'; const ok = await MyPurchaseSystem.purchase(payload.plan.productId); if (!ok) return 'failed'; - try { - await Purchasely.synchronize(); // upload the receipt to Purchasely - } catch (e) { - // surface failure - return 'failed'; - } - return 'success'; // v6 equivalent of processAction(false) + return 'success'; // v6 equivalent of processAction(false); auto-triggers synchronization }); // Called after the interceptor has resolved (Observer mode does not auto-close). @@ -1169,7 +1149,7 @@ async function onPurchaseSuccess(request) { ### Cordova Observer-mode post-purchase (v6) -In v6 the Cordova `purchase` interceptor returns a `Purchasely.InterceptResult`. Run your billing flow, call `synchronize(success, error)`, resolve with `Purchasely.InterceptResult.success`, then close the presentation after the handler has resolved. There is no `onProcessAction` in Cordova v6. +In v6 the Cordova `purchase` interceptor returns a `Purchasely.InterceptResult`. Run your billing flow, then resolve with `Purchasely.InterceptResult.success` — this already triggers synchronization automatically, so do **not** also call `synchronize(success, error)` here — then close the presentation after the handler has resolved. There is no `onProcessAction` in Cordova v6. **Cordova (JavaScript)** @@ -1177,13 +1157,7 @@ In v6 the Cordova `purchase` interceptor returns a `Purchasely.InterceptResult`. Purchasely.interceptAction(Purchasely.PresentationAction.purchase, function(info, parameters) { return MyPurchaseSystem.purchase(parameters.plan).then(function(ok) { if (!ok) return Purchasely.InterceptResult.failed; - - return new Promise(function(resolve) { - Purchasely.synchronize( - function() { resolve(Purchasely.InterceptResult.success); }, - function() { resolve(Purchasely.InterceptResult.failed); } - ); - }); + return Purchasely.InterceptResult.success; // auto-triggers synchronization }); }); @@ -1198,7 +1172,7 @@ For chained follow-up placements on cross-platform SDKs, see `../../references/c Some apps display a follow-up paywall after a successful purchase — a thank-you screen, a premium feature tour, a one-tap upsell. This is **not part of the SDK contract**: it's just another presentation built for whatever placement ID you've configured on the Console (pick your own, e.g. `"post_purchase"`, `"thank_you"`, `"premium_welcome"`). -If the chained placement's audience targets subscribers, **`synchronize()` must complete first** — otherwise the build resolves against stale state and may return a deactivated/fallback presentation. On iOS, that's why the `synchronizeReceipt()` await above matters; on Android, fire-and-forget `synchronize()` is usually fast enough. +If the chained placement's audience targets subscribers, the automatic synchronization triggered by returning `SUCCESS`/`success` from the purchase interceptor needs a moment to complete before you fetch the follow-up placement — otherwise the build may resolve against stale state and return a deactivated/fallback presentation. Since v6 gives you no hook to await that automatic sync, either (a) fetch the follow-up placement from the post-dismissal handler rather than immediately after the interceptor resolves — dismissal already gives the automatic sync time to land — or (b) if you need a hard guarantee, explicitly call and `await Purchasely.synchronize()` yourself at that point. That call is legitimate (not redundant) because it happens **outside** the interceptor's own resolution path. ```swift // iOS — after closeAllScreens() above @@ -1246,7 +1220,7 @@ Once Steps 1-8 are in place and verified, walk the user through the **optional b | Feature | When to suggest | Reference | |---------|-----------------|-----------| | **Preload paywalls** — call `fetchPresentation` ahead of the display (e.g. on app launch, on screen mount) and keep the result for instant display. Avoids the FlowsManager step accumulation on every re-fetch. | Any production integration — significant perceived-perf win | `../../references/concepts/presentation-cache.md` | -| **Campaigns** — schedule paywalls (Black Friday, anniversary), centralise display rules across placements, trigger paywalls on events. Requires SDK ≥ 5.1.0 and `allowDeeplink(true)` (v6 name on native, Flutter, React Native, and Cordova; default is `true` on native / Flutter / Cordova v6 but `false` on React Native, and Android auto-intercepts deeplinks with zero code). | Any team running marketing operations | `../../references/concepts/campaigns.md` | +| **Campaigns** — schedule paywalls (Black Friday, anniversary), centralise display rules across placements, trigger paywalls on events. Requires SDK ≥ 5.1.0. `allowDeeplink` (v6 name on native, Flutter, React Native, and Cordova) defaults to `true` on **every** v6 platform including React Native — the RN builder simply omits the key when `.allowDeeplink(...)` isn't called, and the native default applies. `allowCampaigns` also defaults to `true` in v6 (was `false` in v5) on iOS/Android/Flutter. Android auto-intercepts deeplinks with zero code. | Any team running marketing operations | `../../references/concepts/campaigns.md` | | **Promotional offers & promo codes** — retain / win back subscribers with Apple promotional offers, Google developer-determined offers, App Store / Play Store offer codes. Requires SDK ≥ 4.0.0. | Apps with churn, seasonal promos, win-back funnels | `../../references/concepts/promotional-offers.md` | | **Analytics integration** — forward Purchasely UI events to Firebase / Amplitude / AppsFlyer (client-side) and subscription lifecycle events via 3rd-party integrations / webhooks (server-side, recommended). | Any team with an analytics stack — recommend a single analytics wrapper / manager to centralise the routing | `../../references/concepts/analytics-integration.md` | | **Subscription gating + restore** — gate premium content via `userSubscriptions`, restore purchases from Settings | Any app with premium features | `../../references/concepts/subscription-checks.md` | diff --git a/purchasely/skills/purchasely-migrate/SKILL.md b/purchasely/skills/purchasely-migrate/SKILL.md index 3cd2b44..92130e8 100644 --- a/purchasely/skills/purchasely-migrate/SKILL.md +++ b/purchasely/skills/purchasely-migrate/SKILL.md @@ -1,11 +1,11 @@ --- name: purchasely-migrate -description: "Use when migrating an existing Purchasely SDK integration between major SDK versions. Supports native Android (Kotlin & Java), native iOS (Swift & Objective-C), Flutter, React Native, and Cordova v5.x to v6 — handles every v5→v6 breaking change so a project can be upgraded in a single prompt. Target pins: React Native 6.0.0-rc.2; native iOS / Android / Flutter / Cordova 6.0.0-rc.1." +description: "Use when migrating an existing Purchasely SDK integration between major SDK versions. Supports native Android (Kotlin & Java), native iOS (Swift & Objective-C), Flutter, React Native, and Cordova v5.x to v6 — handles every v5→v6 breaking change so a project can be upgraded in a single prompt. Target pins: native iOS 6.0.0 (stable GA); native Android 6.0.1 (stable GA — Android never had a 6.0.0 tag); Flutter 6.0.0 (stable); React Native 6.0.0-rc.3; Cordova 6.0.0-rc.3." --- # Purchasely SDK Migration Guide -You are migrating an existing Purchasely SDK integration. You must edit the user's project and verify each migration phase with the platform build/test commands. **Native Android (Kotlin & Java), native iOS (Swift & Objective-C), Flutter, React Native, and Cordova v5.x → v6 are supported** (React Native pins `6.0.0-rc.2`; native iOS / Android / Flutter / Cordova pin `6.0.0-rc.1`). +You are migrating an existing Purchasely SDK integration. You must edit the user's project and verify each migration phase with the platform build/test commands. **Native Android (Kotlin & Java), native iOS (Swift & Objective-C), Flutter, React Native, and Cordova v5.x → v6 are supported** (native iOS pins `6.0.0`, stable GA; native Android pins `6.0.1`, stable GA — Android never had a `6.0.0` tag, the line went rc.1 → rc.2 → rc.3 → `6.0.1`; Flutter pins `6.0.0`, stable; React Native pins `6.0.0-rc.3`; Cordova pins `6.0.0-rc.3`). The goal is a **one-prompt upgrade**: detect the platform and call-site language, rewrite every v5 API to its v6 form, and leave the project building with no v5-only symbols remaining. @@ -39,11 +39,11 @@ Determine the project's intended mode before rewriting init: if the v5 code did ## Reference files -- `../../references/android/migration-v6.md` — authoritative Android v5.x → v6.0.0-rc.1 migration checklist and API mapping. -- `../../references/ios/migration-v6.md` — authoritative iOS v5.x → v6.0.0-rc.1 migration checklist and API mapping. -- `../../references/flutter/migration-v6.md` — authoritative Flutter v5.x → v6.0.0-rc.1 migration checklist and API mapping. -- `../../references/react-native/migration-v6.md` — authoritative React Native v5.x → v6.0.0-rc.2 migration checklist and API mapping. -- `../../references/cordova/migration-v6.md` — authoritative Cordova v5.x → v6.0.0-rc.1 migration checklist and API mapping. +- `../../references/android/migration-v6.md` — authoritative Android v5.x → v6 migration checklist and API mapping (target `6.0.1`, stable GA). +- `../../references/ios/migration-v6.md` — authoritative iOS v5.x → v6 migration checklist and API mapping (target `6.0.0`, stable GA). +- `../../references/flutter/migration-v6.md` — authoritative Flutter v5.x → v6 migration checklist and API mapping (target `6.0.0`, stable). +- `../../references/react-native/migration-v6.md` — authoritative React Native v5.x → v6 migration checklist and API mapping (target `6.0.0-rc.3`). +- `../../references/cordova/migration-v6.md` — authoritative Cordova v5.x → v6 migration checklist and API mapping (target `6.0.0-rc.3`). - `../../references/android/v5-api-reference.md` / `../../references/ios/v5-api-reference.md` / `../../references/react-native/v5-api-reference.md` / `../../references/cordova/v5-api-reference.md` — the **v5** API surface, used to recognize the legacy code you are replacing. - `../../references/android/api-reference.md` / `../../references/ios/api-reference.md` — the **v6** API surface to migrate to. - `../../references/concepts/running-modes.md` — running modes and the default-mode change. @@ -61,12 +61,12 @@ Before rewriting code, run a Purchasely expert checkpoint. If the harness expose If that subagent is not available, do the checkpoint inline using the `purchasely-sdk-expert` guidance when available, or this fallback checklist: -- Confirm the migration is in scope: native iOS, native Android, Flutter, React Native, or Cordova v5.x → v6 (React Native `6.0.0-rc.2`; native iOS / Android / Flutter / Cordova `6.0.0-rc.1`). +- Confirm the migration is in scope: native iOS, native Android, Flutter, React Native, or Cordova v5.x → v6 (native iOS `6.0.0`, stable GA; native Android `6.0.1`, stable GA — Android never had a `6.0.0` tag; Flutter `6.0.0`, stable; React Native `6.0.0-rc.3`; Cordova `6.0.0-rc.3`). - Confirm the current v5 running mode and whether v6 must set Full explicitly. - Confirm every package / pod / Gradle artifact is pinned exactly to the target version. - Confirm legacy presentation APIs are replaced by the v6 builder / preload / display path for the platform. - Confirm global v5 interceptors are replaced by per-action v6 interceptors returning the right result type. -- Confirm Observer-mode purchase flows call `synchronize()` and use the right dismissal API. +- Confirm Observer-mode purchase flows rely on the automatic synchronization triggered by returning `SUCCESS` from the interceptor (no manual `synchronize()` call needed inside it) and use the right dismissal API. - Confirm removed subscription UI and history APIs are replaced with supported v6 APIs. - Confirm any uncertain signature was checked in the platform migration and API references. @@ -77,8 +77,8 @@ Incorporate corrections before editing files. `$ARGUMENTS` may contain: - `android` / `ios` / `flutter` / `react-native` / `cordova` — target platform. If omitted, detect it from the project files. - `from:5.x` / `from:5.7.4` — optional source version. -- `to:6.0.0-rc.2` — optional target version. Default per platform (React Native `6.0.0-rc.2`; native iOS / Android / Flutter `6.0.0-rc.1`). -- `mavenLocal` — Android only. Add `mavenLocal()` when the native SDK 6.0.0-rc.1 artifact is only available locally. +- `to:6.0.1` — optional target version. Default per platform (native iOS `6.0.0`; native Android `6.0.1`; Flutter `6.0.0`; React Native `6.0.0-rc.3`; Cordova `6.0.0-rc.3`). +- `mavenLocal` — Android only. Add `mavenLocal()` when the native SDK `6.0.1` artifact is only available locally. ## Mandatory Workflow — Android (Kotlin & Java) @@ -86,18 +86,18 @@ Incorporate corrections before editing files. 2. Read `../../references/android/migration-v6.md`, and skim `../../references/android/v5-api-reference.md` so you recognize every legacy symbol. 3. Find current Purchasely usages with ripgrep (these are the v5 symbols to replace): `Purchasely`, `PLYPresentation`, `PLYPresentationAction`, `setPaywallActionsInterceptor`, `processAction`, `fetchPresentation`, `presentationView`, `PLYPresentationProperties`, `PLYPresentationActionParameters`, `PLYPresentationInfo`, `PLYProductViewResult`, `readyToOpenDeeplink`, `isDeeplinkHandled`, `PaywallObserver`, `subscriptionsFragment`, `purchaseHistory`, `isPastSubscriber`, `hasIntroductoryPrice`, `INTRO_`, `TRIAL_`, `presentationId`. 4. Update Gradle first: - - Pin Purchasely Android artifacts to `6.0.0-rc.1` (`io.purchasely:core`, `io.purchasely:google-play`, optional `io.purchasely:player`). There is **no `presentation-compose` artifact** — do not add one. - - Bump Google Play Billing direct dependencies to `8.3.0` (Purchasely `google-play:6.0.0-rc.1` resolves to PBL v8). If the app calls `queryProductDetailsAsync`, update the lambda to read `queryResult.productDetailsList`. + - Pin Purchasely Android artifacts to `6.0.1` (`io.purchasely:core`, `io.purchasely:google-play`, optional `io.purchasely:player`) — Android never had a `6.0.0` tag; the release line went rc.1 → rc.2 → rc.3 → `6.0.1`. There is **no `presentation-compose` artifact** — do not add one. + - Bump Google Play Billing direct dependencies to `8.3.0` (Purchasely `google-play:6.0.1` resolves to PBL v8). If the app calls `queryProductDetailsAsync`, update the lambda to read `queryResult.productDetailsList`. - Add `mavenLocal()` before `google()` / `mavenCentral()` only when requested or required to resolve local artifacts. - - Move to the Gradle/AGP/Kotlin versions required by the SDK (Gradle 9.3.0+, AGP 9, Kotlin 2.2.x / K2, JDK 17 to build, `minSdk 23`, `compileSdk 36`). With AGP 9, remove `org.jetbrains.kotlin.android` (root `apply false`, every module `plugins { }` block, and the version catalog) — Kotlin support is built into AGP. Leaving it applied fails with `Cannot add extension with name 'kotlin', as there is an extension already registered with that name`. Also remove the `android { kotlinOptions { jvmTarget = "..." } }` block once `kotlin-android` is gone. + - Move to the Gradle/AGP/Kotlin versions required by the SDK (Gradle 9.3.0+, AGP 9, Kotlin 2.3.x / K2, JDK 17 to build, `minSdk 23`, `compileSdk 36`). With AGP 9, remove `org.jetbrains.kotlin.android` (root `apply false`, every module `plugins { }` block, and the version catalog) — Kotlin support is built into AGP. Leaving it applied fails with `Cannot add extension with name 'kotlin', as there is an extension already registered with that name`. Also remove the `android { kotlinOptions { jvmTarget = "..." } }` block once `kotlin-android` is gone. - The reified `interceptAction { … }` / `removeActionInterceptor()` are `inline` functions targeting JVM 11 — compile Kotlin modules with `jvmTarget = 11`, or use the non-inline `Class`-based overload. 5. Compile immediately. Treat compiler errors as the migration worklist. 6. Apply API migrations in small passes and compile after each pass. 7. **Rewrite initialization.** Default to the Kotlin DSL `Purchasely { context(...); apiKey(...); stores(...); runningMode(...); … onInitialized { error -> } }`. The callback now receives only a nullable `PLYError` (`start { error -> }`, not `start { isConfigured, error -> }`). **Set `runningMode(PLYRunningMode.Full)` explicitly when the app needs purchase handling/validation** (see the warning at the top). Map `PLYRunningMode.PaywallObserver` → `PLYRunningMode.Observer`. **Keep the fluent `Purchasely.Builder(...).build().start { error -> }` form for Java projects** (no Kotlin source set) or `.java` call sites — the DSL is Kotlin-only. Note: starting without a store is now valid (storeless); purchase APIs then return `PLYError.NoStoreConfigured` in Full mode. -8. **Action interceptor.** Replace the global `setPaywallActionsInterceptor` with per-action interceptors returning `PLYInterceptResult` (`SUCCESS`/`FAILED`/`NOT_HANDLED`; map `processAction(false)`→`SUCCESS`, `processAction(true)`→`NOT_HANDLED`). `PLYPresentationAction` is now a sealed class with typed parameters on each subclass (`Purchase.plan`, `Navigate.url`, …) — `PLYPresentationActionParameters` is gone. Use the reified `interceptAction { info, purchase -> … }` in Kotlin, or the **`Class`-based overload in Java**: `Purchasely.interceptAction(PLYPresentationAction.Purchase.class, (info, action, result) -> result.invoke(PLYInterceptResult.NOT_HANDLED))`. `PLYPresentationInfo` → `PLYInterceptorInfo`. -9. **Presentation API.** Replace `fetchPresentation(...)` / `PLYPresentationProperties` with `PLYPresentation { placementId(...) ; screenId(...) ; contentId(...) ; onPresented{…}; onCloseRequested{}; onDismissed{outcome->} }.preload { loaded, error -> }` (or `.preload()` in a coroutine, or the atomic `display(context, presentation, callback)`). **Do not put `flowId`/`productId`/`planId` on the builder — they are not exposed in v6;** display a Flow via its deeplink `app_scheme://ply/flows/FLOW_ID`. Update imports `io.purchasely.ext.*` → `io.purchasely.ext.presentation.*`. Rename `PLYPresentation.id` → `screenId` (keep `screenId`; do not rename Android code to `presentationId`) and `onClose` → `onCloseRequested`. `display(context)` is non-suspend and returns a `PLYPresentationSession` you can `.await()`. Callbacks now deliver one `PLYPresentationOutcome` (`purchaseResult`/`plan`/`closeReason`/`error`); `PLYProductViewResult` → `PLYPurchaseResult`. **Breaking rename — default dismiss handler:** `Purchasely.setDefaultPresentationResultHandler { outcome -> … }` → `Purchasely.setDefaultPresentationDismissHandler { outcome -> … }` (same name change as iOS). The Android closure already received an `outcome`, so only the method name changes; it still delivers a single `PLYPresentationOutcome` (`purchaseResult`/`plan`/`closeReason`/`error`). +8. **Action interceptor.** Replace the global `setPaywallActionsInterceptor` with per-action interceptors returning `PLYInterceptResult` (`SUCCESS`/`FAILED`/`NOT_HANDLED`; map `processAction(false)`→`SUCCESS`, `processAction(true)`→`NOT_HANDLED`). `PLYPresentationAction` is now a sealed class with typed parameters on each subclass (`Purchase.plan`, `Navigate.url`, …) — `PLYPresentationActionParameters` is gone. Use the reified `interceptAction { info, purchase -> … }` in Kotlin, or the **`Class`-based overload in Java**: `Purchasely.interceptAction(PLYPresentationAction.Purchase.class, (info, action, result) -> result.invoke(PLYInterceptResult.NOT_HANDLED))`. `PLYPresentationInfo` → `PLYInterceptorInfo`. `interceptAction` / `removeActionInterceptor` are member functions of `Purchasely` since rc.3 — no `import io.purchasely.ext.interceptAction` is needed; delete that import if an older tutorial/snippet added it (harmless dead code, not a bug). +9. **Presentation API.** Replace `fetchPresentation(...)` / `PLYPresentationProperties` with `PLYPresentation { placementId(...) ; screenId(...) ; contentId(...) ; onPresented{…}; onCloseRequested{}; onDismissed{outcome->} }.preload { loaded, error -> }` (or `.preload()` in a coroutine, or the atomic `display(context, presentation, callback)`). **Do not put `flowId`/`productId`/`planId` on the builder — they are not exposed in v6;** display a Flow via its deeplink `app_scheme://ply/flows/FLOW_ID`. Update imports `io.purchasely.ext.*` → `io.purchasely.ext.presentation.*`. Rename `PLYPresentation.id` → `screenId` (keep `screenId`; do not rename Android code to `presentationId`) and `onClose` → `onCloseRequested`. `display(context)` is non-suspend and returns a `PLYPresentationSession` you can `.await()`. Callbacks now deliver one `PLYPresentationOutcome` (`purchaseResult`/`plan`/`closeReason`/`error`); `PLYProductViewResult` → `PLYPurchaseResult`. Note on `presentation.close()`: it delegates to `Purchasely.closeAllScreens()` — there is no instance-scoped close on Android, unlike iOS. **Default dismiss handler — no rename needed on Android:** `Purchasely.setDefaultPresentationDismissHandler { outcome -> … }` is, and always was, the correct Android name; `setDefaultPresentationResultHandler` never existed there (only iOS renamed *from* that name in v6 — see the iOS workflow below). Since `6.0.1` its `handler` parameter is nullable — pass `null` to unregister it. 10. **Embedded UI.** Replace `presentationView(...)` with `loaded.buildView(context) { outcome -> }` or `loaded.getFragment { outcome -> }`. For Jetpack Compose there is **no SDK composable** — wrap the view: `AndroidView(factory = { loaded.buildView(it) { outcome -> } })`. Do not reference `io.purchasely:presentation-compose` or a `PLYPresentationView` composable; `PLYPresentationView` is the Android `View` type that `buildView()` returns. -11. **Observer mode.** If the app used `processAction(Boolean)` to gate the SDK on the host purchase flow, port that to a `pendingResult: ((PLYInterceptResult) -> Unit)?` field + a `suspendCancellableCoroutine` bridge inside the new `suspend` interceptor (see `../../references/android/migration-v6.md` → "Observer-mode bridge"). On billing success call `Purchasely.synchronize(onSuccess = { … }, onError = { … })` then resolve with `SUCCESS`; resolve `NOT_HANDLED`/`FAILED` otherwise. Clear `pendingResult` in `close()`/`restart()` before `removeAllActionInterceptors()` to avoid leaking suspended coroutines. +11. **Observer mode.** If the app used `processAction(Boolean)` to gate the SDK on the host purchase flow, port that to a `pendingResult: ((PLYInterceptResult) -> Unit)?` field + a `suspendCancellableCoroutine` bridge inside the new `suspend` interceptor (see `../../references/android/migration-v6.md` → "Observer-mode bridge"). On billing success resolve directly with `SUCCESS`; resolve `NOT_HANDLED`/`FAILED` otherwise. **Do not call `Purchasely.synchronize()` inside the interceptor** — returning `SUCCESS` from a `purchase`/`restore` interceptor already triggers synchronization automatically (a v5 habit worth dropping during the migration, not just carrying it forward as-is). Manual `Purchasely.synchronize(onSuccess = { … }, onError = { … })` remains legitimate only for purchases made **outside** the interceptor (a custom sell screen, BYOS). Clear `pendingResult` in `close()`/`restart()` before `removeAllActionInterceptors()` to avoid leaking suspended coroutines. 12. **Other renames/removals.** Deeplinks: `readyToOpenDeeplink` → `allowDeeplink`, `isDeeplinkHandled(uri, activity)` → `handleDeeplink(uri, activity)`; v6 also auto-intercepts deeplinks (the manual call may become unnecessary, but watch the `singleTask`/`singleTop` + `setIntent()` pitfall). User-attribute mutations now return `Deferred` (`.await()` when you need the result). Replace removed APIs: `subscriptionsFragment()` and the subscription/cancellation UI (build your own from `userSubscriptions`/`userSubscriptionsHistory`), `purchaseHistory()` → `userSubscriptionsHistory()`, `isPastSubscriber()` → derive from history, and all `intro*`/`INTRO_*`/`TRIAL_*` → `offer*`/`OFFER_*`. 13. Update tests to the v6 API and run unit tests. 14. Run the final Android assemble command before reporting completion. @@ -107,12 +107,12 @@ Incorporate corrections before editing files. 1. Detect the iOS project: `*.xcodeproj` / `*.xcworkspace`, `project.yml` (XcodeGen), `Package.swift`, or a `Podfile`. Detect how Purchasely is integrated — **SPM** or **CocoaPods** — and the **call-site language** (Swift vs Objective-C), because both drive the rewrite. 2. Read `../../references/ios/migration-v6.md`, and skim `../../references/ios/v5-api-reference.md` so you recognize every legacy symbol. 3. Find current Purchasely usages with ripgrep: `start(withAPIKey`, `paywallObserver`, `readyToOpenDeeplink`, `isDeeplinkHandled`, `setPaywallActionsInterceptor`, `proceed(`, `fetchPresentation`, `presentationController`, `productController`, `planController`, `PresentationView`, `presentationView`, `productView`, `planView`, `ply/products`, `ply/plans`, `closeDisplayedPresentation`, `PLYProductViewControllerResult`, `PLYPresentationInfo`, `displayMode:`, `setDefaultPresentationResultHandler`, plus Objective-C call sites (`[Purchasely startWithAPIKey`, `PLYPresentation *`). -4. Bump the dependency to `6.0.0-rc.1` first — pin it **exactly** (SPM `exact: "6.0.0-rc.1"`, CocoaPods `pod 'Purchasely', '6.0.0-rc.1'`, Carthage `binary "https://raw.githubusercontent.com/Purchasely/Purchasely-iOS/master/Purchasely.json" == 6.0.0-rc.1` then `carthage update`); floating ranges (`~> 6.0`, `from:`) do not resolve a pre-release and would silently drift. Resolve packages / `pod install` / `carthage update`, and clean the build folder. +4. Bump the dependency to `6.0.0` (stable GA) — SPM `from: "6.0.0"` (Up to Next Major, primary/preferred) or CocoaPods `pod 'Purchasely', '~> 6.0'` are the recommended forms now that the SDK is stable; Carthage `binary "https://raw.githubusercontent.com/Purchasely/Purchasely-iOS/master/Purchasely.json" ~> 6.0` then `carthage update`. Resolve packages / `pod install` / `carthage update`, and clean the build folder. 5. Add `@preconcurrency import Purchasely` at SDK call sites compiled under Swift 6 strict concurrency, and relax test targets to `SWIFT_STRICT_CONCURRENCY = minimal`. 6. Compile immediately. Treat compiler errors as the migration worklist; apply API migrations in small passes and recompile after each. 7. **Rewrite initialization** from the removed `Purchasely.start(withAPIKey:…)` to the fluent chain `Purchasely.apiKey("…")…start()`. Prefer Swift async (`try await …start()`); use the completion form (`.start { error in }`, single `Error?`) when async is impractical or for Objective-C interop. **Objective-C:** `[[[[Purchasely apiKey:@"…"] appUserId:@"…"] runningMode:PLYRunningModeFull] startWithInitialized:^(NSError *e){}]`. **Set `.runningMode(.full)` explicitly when the app needs purchase handling/validation** (default is now `.observer`). Map `.paywallObserver` → `.observer`. Migrate the deprecated pre-`start` `set*` class funcs (`setEnvironment`, `setThemeMode`, …) to chain modifiers. 8. **Replace the interceptor.** `setPaywallActionsInterceptor` → typed `Purchasely.interceptAction(.login/.navigate/.purchase/.restore)` closures that are `async` and **return** a `PLYInterceptResult` (`proceed(true)` → `.notHandled`, `proceed(false)` → `.success`, failure → `.failed`). The completion-handler form `interceptAction(.x) { info, params, completion in completion(.success) }` is available for Objective-C / non-async call sites. `PLYPresentationInfo` → `PLYInterceptorInfo`. In Observer mode, `await` the native StoreKit flow inside the closure and return its result directly — iOS needs no coroutine bridge. -9. **Migrate presentation loading.** `fetchPresentation(...)` → `PLYPresentationBuilder.forPlacementId(...)` (or `.forScreenId(...)` / `.from(placementId:)`) `.build().preload { … }` (or `try await …preload()`); use `.onPresented`/`.onDismissed`. The convenience `Purchasely.display(for: placementId, transition: …)` replaces the old `display(for:displayMode:)` (param renamed `displayMode:` → `transition:`). The dismissal tuple `(PLYProductViewControllerResult, PLYPlan?)` becomes one `PLYPresentationOutcome` (`purchaseResult`/`plan`/`closeReason`/`presentation`/`error`). `Purchasely.closeDisplayedPresentation()` → `Purchasely.closeAllScreens()`. **Breaking rename — default dismiss handler:** `Purchasely.setDefaultPresentationResultHandler { result, plan in … }` → `Purchasely.setDefaultPresentationDismissHandler { outcome in … }`. The method was renamed **and** retyped: the closure no longer receives a `(result, plan)` tuple but a single `PLYPresentationOutcome` (read `outcome.purchaseResult` — `.purchased`/`.restored`/`.cancelled`/`.none` — plus `outcome.closeReason`/`outcome.plan`/`outcome.presentation`/`outcome.error`). (iOS-only rename — Android keeps the `setDefaultPresentationResultHandler` name with a single-`outcome` callback.) +9. **Migrate presentation loading.** `fetchPresentation(...)` → `PLYPresentationBuilder.forPlacementId(...)` (or `.forScreenId(...)` / `.from(placementId:)`) `.build().preload { … }` (or `try await …preload()`); use `.onPresented`/`.onDismissed`. The convenience `Purchasely.display(for: placementId, transition: …)` replaces the old `display(for:displayMode:)` (param renamed `displayMode:` → `transition:`). The dismissal tuple `(PLYProductViewControllerResult, PLYPlan?)` becomes one `PLYPresentationOutcome` (`purchaseResult`/`plan`/`closeReason`/`presentation`/`error`). `Purchasely.closeDisplayedPresentation()` → `Purchasely.closeAllScreens()`. **Breaking rename — default dismiss handler:** `Purchasely.setDefaultPresentationResultHandler { result, plan in … }` → `Purchasely.setDefaultPresentationDismissHandler { outcome in … }`. The method was renamed **and** retyped: the closure no longer receives a `(result, plan)` tuple but a single `PLYPresentationOutcome` (read `outcome.purchaseResult` — `.purchased`/`.restored`/`.cancelled`/`.none` — plus `outcome.closeReason`/`outcome.plan`/`outcome.presentation`/`outcome.error`). (iOS-only rename — `setDefaultPresentationResultHandler` never existed on Android; Android has always used `setDefaultPresentationDismissHandler` with a single-`outcome` callback, so there is nothing to rename there.) 10. **Embedded / SwiftUI.** The removed `controller.PresentationView` — and the removed `Purchasely.productView(...)` / `planView(...)` / `presentationView(...)` factories — become `presentation.swiftUIView` for SwiftUI (returns `nil` for `.deactivated`); UIKit consumers keep `presentation.controller` (a `PLYPresentationViewController`, wrap in `UIViewControllerRepresentable` if needed). The UIKit factories `presentationController(...)` / `productController(...)` / `planController(...)` are removed too. 11. **`PLYPresentation` is now a protocol.** In Objective-C change `PLYPresentation *` → `id`; in Swift `any PLYPresentation` and `PLYPresentation` both compile. Reading members/calling methods is unchanged. 12. **Deeplinks.** `readyToOpenDeeplink(_:)` → `allowDeeplink(_:)`, `isDeeplinkHandled(deeplink:)` → `handleDeeplink(_:)`. iOS does **not** auto-intercept — keep passing deeplinks via `Purchasely.handleDeeplink(_:)` from `AppDelegate`/`SceneDelegate`. The `ply/products/*` and `ply/plans/*` deeplink formats are **removed** — repoint them to `ply/presentations/` or `ply/placements/`. @@ -121,12 +121,12 @@ Incorporate corrections before editing files. ## Mandatory Workflow — Flutter -The Flutter plugin migration **adapts the integration to the Purchasely 6.0 native SDKs** (iOS `Purchasely 6.0.0-rc.1`, Android `io.purchasely:core 6.0.0-rc.1`). Three areas are breaking: **starting the SDK**, **displaying / preloading / closing a presentation**, and the **action interceptor**. Everything else on the `Purchasely` class is source-compatible except for removed v5 aliases; deeplinks use the v6 names (`allowDeeplink`, `handleDeeplink`). All v6 public Dart types carry the **`PLY` prefix** (`PLYPresentationBuilder`, `PLYPresentationOutcome`, `PLYTransition`, …); the one exception is SDK init — accessed via `Purchasely.apiKey(...)` (a static method returning `PurchaselyBuilder`). +The Flutter plugin migration **adapts the integration to the Purchasely 6.0 native SDKs** (iOS `Purchasely 6.0.0` stable GA, Android `io.purchasely:core 6.0.1` stable GA — Android never had a `6.0.0` tag). Three areas are breaking: **starting the SDK**, **displaying / preloading / closing a presentation**, and the **action interceptor**. Everything else on the `Purchasely` class is source-compatible except for removed v5 aliases; deeplinks use the v6 names (`allowDeeplink`, `handleDeeplink`). All v6 public Dart types carry the **`PLY` prefix** (`PLYPresentationBuilder`, `PLYPresentationOutcome`, `PLYTransition`, …); the one exception is SDK init — accessed via `Purchasely.apiKey(...)` (a static method returning `PurchaselyBuilder`). 1. Detect the Flutter project: a `pubspec.yaml` that depends on `purchasely_flutter` (and optionally `purchasely_google` / `purchasely_android_player`). The Dart call sites under `lib/` (and the example app) drive the rewrite; the iOS host lives in `ios/` (a `Podfile` pulling the `Purchasely` pod via the plugin podspec) and the Android host in `android/` (Gradle pulling `io.purchasely:core`). Do **not** treat a React Native or Cordova project as Flutter — React Native has its own builder-based workflow below, and Cordova has its own method-based workflow below. -2. Read `../../references/flutter/migration-v6.md` — the authoritative Flutter v5.x → v6.0.0-rc.1 checklist and old→new API mapping. Use it as the source of truth for every rewrite below. +2. Read `../../references/flutter/migration-v6.md` — the authoritative Flutter v5.x → v6 checklist and old→new API mapping (target `6.0.0`, stable). Use it as the source of truth for every rewrite below. 3. Find current v5 Purchasely usages with ripgrep (these are the symbols to replace): `Purchasely.start(`, `fetchPresentation`, `presentPresentation`, `presentPresentationForPlacement`, `presentPresentationWithIdentifier`, `presentProductWithIdentifier`, `presentPlanWithIdentifier`, `setPaywallActionInterceptorCallback`, `onProcessAction`, `PLYPaywallAction`, `PLYPaywallInfo`, `PLYPaywallActionParameters`, `PresentPresentationResult`, `PaywallActionInterceptorResult`, `closePresentation`, `hidePresentation`, `showPresentation`, `getPresentationView`, `setDefaultPresentationResultHandler`, `setDefaultPresentationResultCallback`, `readyToOpenDeeplink`, `isDeeplinkHandled`, `presentSubscriptions`, `PLYRunningMode`, `PLYLogLevel`, `PLYPurchaseResult`. Also search for **unprefixed early-v6 Dart type names** (these did not exist in v5 but may appear in early v6 codebases before the 2026-06-24 PLY-prefix rename): `PresentationBuilder`, `PresentationRequest`, `PresentationOutcome`, `PresentationType`, `PresentationActionKind`, `PurchaseResult`, `CloseReason`, `RunningMode`, `LogLevel`, `StorekitVersion`, `InterceptResult`, `NavigatePayload`, `PurchasePayload`, `ClosePayload`, `OpenPresentationPayload`, `OpenPlacementPayload`, `WebCheckoutPayload` — all must be replaced with their `PLY*` equivalents. Note: `PurchaselyBuilder` was NOT given the PLY prefix — it stays `PurchaselyBuilder`, accessed via `Purchasely.apiKey(...)`. -4. **Bump the package pins first.** In every `pubspec.yaml`, pin all three Purchasely packages **exactly** to `6.0.0-rc.1` (never a floating `^`/range — a caret would not resolve a pre-release and could silently drift): `purchasely_flutter: 6.0.0-rc.1`, and where present `purchasely_google: 6.0.0-rc.1` and `purchasely_android_player: 6.0.0-rc.1`. Run `flutter pub get` in each package. The plugin pulls iOS `Purchasely 6.0.0-rc.1` (CocoaPods trunk) and Android `io.purchasely:core 6.0.0-rc.1` (Maven Central) from the public repos — no `mavenLocal()` and no development pod required. +4. **Bump the package pins first.** In every `pubspec.yaml`, pin all three Purchasely packages **exactly** to `6.0.0` (recommended for reproducibility, though the SDK is stable GA now so a caret range would also resolve): `purchasely_flutter: 6.0.0`, and where present `purchasely_google: 6.0.0` and `purchasely_android_player: 6.0.0`. Run `flutter pub get` in each package. The plugin pulls native iOS `Purchasely 6.0.0` (CocoaPods trunk) and Android `io.purchasely:core 6.0.1` (Maven Central — Android never had a `6.0.0` tag) from the public repos — no `mavenLocal()` and no development pod required. 5. **Bump the Android host build config.** In the app's `android/app/build.gradle(.kts)` (and any module that overrides them) set `compileSdk 36`, `targetSdk 35`, `minSdk 23` (raise from the v5 `compileSdk 33`). iOS deployment target is `13.4`. Then `cd ios && pod install --repo-update` to pull the pinned `Purchasely` pod. 6. Run `flutter pub get` and `flutter analyze` immediately. Treat analyzer errors as the migration worklist; apply API migrations in small passes and re-run `flutter analyze` after each. 7. **Rewrite initialization.** Replace `await Purchasely.start(apiKey: …, androidStores: …, storeKit1: …, logLevel: PLYLogLevel.…, runningMode: PLYRunningMode.…, userId: …)` with `await Purchasely.apiKey('…').appUserId(id).runningMode(PLYRunningMode.full).logLevel(PLYLogLevel.error).stores([PLYStore.google]).storekitVersion(PLYStorekitVersion.storeKit2).allowDeeplink(true).allowCampaigns(true).start()`. The new enums are `PLYRunningMode{observer, full}` (**only two values** — `transactionOnly` and `paywallObserver` no longer exist), `PLYLogLevel{debug, info, warn, error}`, `PLYStorekitVersion{storeKit1, storeKit2}`. **The default `PLYRunningMode` is now `PLYRunningMode.observer` (was Full)** — if the app relies on Purchasely to handle and validate purchases, you **must** pass `.runningMode(PLYRunningMode.full)` explicitly (this silent default change is the single most impactful v6 break; see the warning at the top). Map a v5 `storeKit1: true` to `.storekitVersion(PLYStorekitVersion.storeKit1)` (default is `storeKit2`). @@ -135,19 +135,19 @@ The Flutter plugin migration **adapts the integration to the Purchasely 6.0 nati 10. **Presentation lifecycle (close / back).** A loaded `PLYPresentation` exposes `.display([PLYTransition])`, `.close()` and `.back()`. Replace `Purchasely.closePresentation()` / `hidePresentation()` / `close()` with `presentation.close()` and `showPresentation()` with `presentation.display()`. **There is NO `closePresentation()` / `hidePresentation()` / `closeAllScreens()` in Flutter v6** — dismiss via `presentation.close()`. 11. **Inline / embedded UI.** Replace `Purchasely.getPresentationView(...)` with the `PLYPresentationView(request: …)` widget (from `package:purchasely_flutter/native_view_widget.dart`); build the `PLYPresentationRequest` (e.g. with `.onDismissed((outcome) => …)`) and pass it to the widget. 12. **Deeplinks.** Move deeplink permission onto the builder: `.allowDeeplink(true)`. At runtime use `Purchasely.handleDeeplink(uri)` and `Purchasely.allowDeeplink(bool)`. **`readyToOpenDeeplink` and `isDeeplinkHandled` were REMOVED in v6** — remove every call to them. For the default dismiss handler (deeplinks, campaigns, promoted IAP), replace `setDefaultPresentationResultHandler` / `setDefaultPresentationResultCallback` with `Purchasely.setDefaultPresentationDismissHandler((outcome) { … })`. v6 displays deeplinks/campaigns immediately by default. -13. **`synchronize()` now reports completion (BREAKING signature).** `Purchasely.synchronize()` now returns **`Future`** (was `Future`) — it **resolves with `true` when synchronization completes** and **throws a `PlatformException` on failure** (was fire-and-forget). Update call sites that ignore the return value and wrap in `try/catch` if you chain a subscriber-targeted presentation. -14. **Removed: `presentSubscriptions()` (BREAKING).** `Purchasely.presentSubscriptions()` is **removed entirely** from Flutter v6 (the native subscriptions screen was removed on both platforms) — it is **not** a no-op, the method no longer exists. Remove every call and build your own subscriptions screen from `Purchasely.userSubscriptions()` / `Purchasely.userSubscriptionsHistory()`. `Purchasely.displaySubscriptionCancellationInstruction()` is kept for source-compatibility but is a **no-op on both platforms**. +13. **`synchronize()` now reports completion (BREAKING signature).** `Purchasely.synchronize()` now returns **`Future`** (was `Future`) — it **resolves with `true` when synchronization completes** and **throws a `PlatformException` on failure** (was fire-and-forget). Update call sites that ignore the return value and wrap in `try/catch` if you chain a subscriber-targeted presentation. Note: returning `InterceptResult.success` from the `purchase`/`restore` interceptor already triggers this synchronization automatically — a manual call inside that same interceptor is redundant, not required; keep manual `synchronize()` calls only for purchases made outside the interceptor. +14. **Removed: `presentSubscriptions()` (BREAKING).** `Purchasely.presentSubscriptions()` is **removed entirely** from Flutter v6 (the native subscriptions screen was removed on both platforms) — it is **not** a no-op, the method no longer exists. Remove every call and build your own subscriptions screen from `Purchasely.userSubscriptions()` / `Purchasely.userSubscriptionsHistory()`. `Purchasely.displaySubscriptionCancellationInstruction()` is **also removed on Flutter — not a no-op** (the underlying Android method it wrapped was already undocumented upstream and isn't present at all) — remove every call and build custom UI from `userSubscriptions()` / `userSubscriptionsHistory()`. 15. **Unchanged (do not rewrite).** Everything outside the paywall surface keeps source-compatible `Purchasely.*` signatures: `purchaseWithPlanVendorId`, `signPromotionalOffer`, `restoreAllProducts`, `userLogin` / `userLogout` / `isAnonymous`, `allProducts` / `productWithIdentifier` / `planWithIdentifier` / `isEligibleForIntroOffer`, `userSubscriptions` / `userSubscriptionsHistory`, user attributes, `listenToEvents` / `listenToPurchases`, dynamic offerings, consent, `setLanguage` / `setThemeMode` / `setLogLevel` / `setDebugMode`. The bridge is still MethodChannel/EventChannel — do not touch it. 16. Update Dart tests to the v6 API. **Verify before reporting completion:** run `flutter analyze` (0 issues), `flutter test` (all pass), then `flutter build ios --simulator` and `flutter build apk --debug` (build the example app if the package itself is plugin-only) — fix every failure and re-run until all are clean. Do not declare the migration done from edits or reasoning alone; include the exact commands and outcomes. ## Mandatory Workflow — React Native -The React Native plugin migration **adapts the integration to the Purchasely 6.0 native SDKs** (iOS `Purchasely 6.0.0-rc.2`, Android `io.purchasely:core 6.0.0-rc.2`). The TypeScript public symbols use the `Purchasely.builder(...)` / `Purchasely.presentation` (`PLYPresentationBuilder`) / `Purchasely.interceptAction(...)` v6 surface — options are plain **strings**, not enums (this is the key difference from Flutter). Four areas are breaking: **starting the SDK**, **displaying / preloading / closing a presentation**, the **action interceptor**, and the **deeplink API** (`isDeeplinkHandled` / `readyToOpenDeeplink` are removed — use `handleDeeplink` / `allowDeeplink`). Everything else on the `Purchasely` module is source-compatible. +The React Native plugin migration **adapts the integration to the Purchasely 6.0 native SDKs** (iOS `Purchasely 6.0.0-rc.3`, Android `io.purchasely:core 6.0.0-rc.3`). The TypeScript public symbols use the `Purchasely.builder(...)` / `Purchasely.presentation` (`PLYPresentationBuilder`) / `Purchasely.interceptAction(...)` v6 surface — options are plain **strings**, not enums (this is the key difference from Flutter). Four areas are breaking: **starting the SDK**, **displaying / preloading / closing a presentation**, the **action interceptor**, and the **deeplink API** (`isDeeplinkHandled` / `readyToOpenDeeplink` are removed — use `handleDeeplink` / `allowDeeplink`). Everything else on the `Purchasely` module is source-compatible. 1. Detect the React Native project: a `package.json` that depends on `react-native-purchasely` (and optionally `@purchasely/react-native-purchasely-google` / `-android-player` / `-amazon` / `-huawei`). The TS/JS call sites under `src/` (and the example app) drive the rewrite; the iOS host lives in `ios/` (a `Podfile` pulling the `Purchasely` pod via the plugin podspec) and the Android host is autolinked (Gradle pulling `io.purchasely:core`). Do **not** treat a Cordova project as React Native — Cordova has its own method-based workflow below. -2. Read `../../references/react-native/migration-v6.md` — the authoritative React Native v5.x → v6.0.0-rc.2 checklist and old→new API mapping — and skim `../../references/react-native/v5-api-reference.md` so you recognize every legacy symbol. Use the migration doc as the source of truth for every rewrite below. +2. Read `../../references/react-native/migration-v6.md` — the authoritative React Native v5.x → v6 checklist and old→new API mapping (target `6.0.0-rc.3`, npm `latest`) — and skim `../../references/react-native/v5-api-reference.md` so you recognize every legacy symbol. Use the migration doc as the source of truth for every rewrite below. 3. Find current v5 Purchasely usages with ripgrep (these are the symbols to replace): `Purchasely.start(`, `startWithAPIKey`, `fetchPresentation`, `presentPresentation`, `presentPresentationForPlacement`, `presentPresentationWithIdentifier`, `presentProductWithIdentifier`, `presentPlanWithIdentifier`, `showPresentation`, `hidePresentation`, `closePresentation`, `setPaywallActionInterceptorCallback`, `setPaywallActionInterceptor`, `onProcessAction`, `setDefaultPresentationResultCallback`, `setDefaultPresentationResultHandler`, `readyToOpenDeeplink`, `isDeeplinkHandled`, `presentSubscriptions`, `displaySubscriptionCancellationInstruction`, `clientPresentationDisplayed`, `clientPresentationClosed`, `PaywallAction`, `ProductResult`, `RunningMode`, `LogLevels`. -4. **Bump the package pins first.** In `package.json`, pin every Purchasely package **exactly** to `6.0.0-rc.2` (never a floating `^`/range — a caret would not resolve a pre-release and could silently drift): `react-native-purchasely: 6.0.0-rc.2`, and where present `@purchasely/react-native-purchasely-google` / `-android-player` / `-amazon` / `-huawei` at the same `6.0.0-rc.2`. Run `yarn install` (or `npm install`). The plugin pulls iOS `Purchasely 6.0.0-rc.2` (CocoaPods trunk) and Android `io.purchasely:core 6.0.0-rc.2` (Maven Central) from the public repos. +4. **Bump the package pins first.** In `package.json`, pin every Purchasely package **exactly** to `6.0.0-rc.3` (never a floating `^`/range — a caret would not resolve a pre-release and could silently drift): `react-native-purchasely: 6.0.0-rc.3`, and where present `@purchasely/react-native-purchasely-google` / `-android-player` / `-amazon` / `-huawei` at the same `6.0.0-rc.3`. Run `yarn install` (or `npm install`). The plugin pulls iOS `Purchasely 6.0.0-rc.3` (CocoaPods trunk) and Android `io.purchasely:core 6.0.0-rc.3` (Maven Central) from the public repos. 5. **Bump the Android host build config.** In `android/build.gradle` ensure `minSdkVersion` is at least **23** (the v6 SDK minimum, bumped from 21) and align `compileSdk`/`targetSdk` on the existing app (35+; the library builds against `compileSdk 35`). iOS deployment target is `13.4`. Then `cd ios && pod install --repo-update` to pull the pinned `Purchasely` pod. 6. Run `yarn install` and `yarn typecheck` (or `tsc --noEmit`) immediately. Treat type errors as the migration worklist; apply API migrations in small passes and re-run `yarn typecheck` after each. 7. **Rewrite initialization.** Replace `await Purchasely.start({ apiKey, logLevel, runningMode, stores, userId, storeKit1, ... })` with the fluent builder `await Purchasely.builder('API_KEY').appUserId(id).runningMode('full').logLevel('error').stores(['google']).storekitVersion('storeKit2').allowDeeplink(true).allowCampaigns(true).start()`. Options are **strings**: `runningMode` `'observer' | 'full'`, `logLevel` `'debug' | 'info' | 'warn' | 'error'`, `stores` `['google' | 'huawei' | 'amazon']`, `storekitVersion` `'storeKit1' | 'storeKit2'`. **The default `runningMode` is now `'observer'` (was Full)** — if the app relies on Purchasely to handle and validate purchases, you **must** pass `.runningMode('full')` explicitly (this silent default change is the single most impactful v6 break; see the warning at the top). Map a v5 `storeKit1: true` to `.storekitVersion('storeKit1')` (default is `'storeKit2'`). `start()` returns a `Promise` — await it before using the SDK. @@ -157,26 +157,26 @@ The React Native plugin migration **adapts the integration to the Purchasely 6.0 11. **Inline / embedded UI.** The embedded `PLYPresentationView` component remains — it now also accepts a preloaded `request` prop (` …} />`), and still supports the `placementId` fallback. Its `onPresentationClosed` receives a `PLYPresentationViewResult` (`{ result: ProductResult, plan }`), NOT the 5-field outcome. 12. **Deeplinks — ⚠️ `isDeeplinkHandled` is REMOVED and renamed to `handleDeeplink`.** Move deeplink permission onto the builder: `.allowDeeplink(true)` (the v5 `readyToOpenDeeplink(true)` runtime call is replaced by this builder modifier, or the standalone `Purchasely.allowDeeplink(true)`). At runtime, **replace `Purchasely.isDeeplinkHandled('app://ply/…')` with `await Purchasely.handleDeeplink('app://ply/…')`** — the v5 name is removed with no alias (this matches native iOS/Android and Flutter). For a cold-start deeplink, pass it to `.handleDeeplink(uri)` on the builder so it replays after `start()`. v6 displays deeplinks/campaigns immediately by default. 13. **Default presentation dismiss handler.** Replace `setDefaultPresentationResultCallback` / `setDefaultPresentationResultHandler` with `Purchasely.setDefaultPresentationDismissHandler((outcome) => …)` for presentations the SDK opens itself (campaigns, deeplinks, Promoted IAP). It returns a subscription (`.remove()`); a single handler is active (re-register replaces), or call `Purchasely.removeDefaultPresentationDismissHandler()`. `outcome.presentation` is always populated, identifying the closed screen. -14. **`synchronize()` now reports completion.** `await Purchasely.synchronize()` now returns a `Promise` that **resolves when synchronization completes** and **rejects on failure** (was fire-and-forget; source-compatible — fire-and-forget still works). Wrap it in `try/catch` if you act on the result before chaining a subscriber-targeted presentation. +14. **`synchronize()` now reports completion.** `await Purchasely.synchronize()` now returns a `Promise` that **resolves when synchronization completes** and **rejects on failure** (was fire-and-forget; source-compatible — fire-and-forget still works). Wrap it in `try/catch` if you act on the result before chaining a subscriber-targeted presentation. Note: returning `'success'` from the `purchase`/`restore` interceptor already triggers this synchronization automatically — a manual call inside that same interceptor is redundant, not required; keep manual `synchronize()` calls only for purchases made outside the interceptor. 15. **Removed: `presentSubscriptions()` (BREAKING).** `Purchasely.presentSubscriptions()` is **removed entirely** from React Native v6 (the native subscriptions screen was removed on both platforms) — it is **not** a no-op, the method no longer exists. `displaySubscriptionCancellationInstruction()` and `clientPresentationDisplayed` / `clientPresentationClosed` are gone too. Remove every call and build your own subscriptions screen from `Purchasely.userSubscriptions()` / `Purchasely.userSubscriptionsHistory()`. 16. **Unchanged (do not rewrite).** Everything outside the paywall + deeplink surface keeps source-compatible `Purchasely.*` signatures: `userLogin` / `userLogout` / `getAnonymousUserId` / `isAnonymous`, `allProducts` / `productWithIdentifier` / `planWithIdentifier` / `isEligibleForIntroOffer`, `purchaseWithPlanVendorId`, `signPromotionalOffer`, `userSubscriptions` (`{ invalidateCache }`) / `userSubscriptionsHistory`, `restoreAllProducts` / `silentRestoreAllProducts`, all `setUserAttributeWith*` (incl. `Int` / `Double` aliases) / `incrementUserAttribute` / `userAttributes` / `userAttribute` / `clearUserAttribute(s)`, listeners (`addEventListener` / `addPurchasedListener` / `addUserAttributeSet|RemovedListener`, plus `listenToEvents` / `listenToPurchases` aliases), `setLanguage` / `setThemeMode` / `setLogLevel` / `setDebugMode`, `allowCampaigns`, `revokeDataProcessingConsent`, dynamic offerings, and the **`PLYPresentationView`** component. The bridge is still the native module — do not touch it. 17. Update TS tests (`src/__tests__/`) to the v6 API and run them. **Verify before reporting completion:** run `yarn install`, then `yarn typecheck && yarn lint && yarn test` (all pass), then build the example app's native target (`yarn example:ios` and/or `yarn example:android`, or the project's equivalent) — fix every failure and re-run until all are clean. Do not declare the migration done from edits or reasoning alone; include the exact commands and outcomes. ## Mandatory Workflow — Cordova -The Cordova plugin migration **adapts the integration to the Purchasely 6.0 native SDKs** (iOS `Purchasely 6.0.0-rc.2`, Android `io.purchasely:core 6.0.0-rc.2`). Unlike native iOS/Android, Flutter, and React Native — which introduced builder APIs — the **Cordova JavaScript surface stays method-based**: the native bridges were rewired to the v6 SDKs behind the existing `cordova.exec` actions. Most methods keep their name and signature, but there are **three breaking surfaces** — `start()` (now an options object), the **action interceptor** (now per-action `interceptAction` + `InterceptResult`), and presentation **display mode** (`isFullscreen` → `TransitionType`) — plus several renamed/removed methods. There is **no v5 source-compatibility shim**: renamed methods were renamed, not aliased, and removed ones no longer resolve. +The Cordova plugin migration **adapts the integration to the Purchasely 6.0 native SDKs** (iOS `Purchasely 6.0.0-rc.3`, Android `io.purchasely:core 6.0.0-rc.3`). Unlike native iOS/Android, Flutter, and React Native — which introduced builder APIs — the **Cordova JavaScript surface stays method-based**: the native bridges were rewired to the v6 SDKs behind the existing `cordova.exec` actions. Most methods keep their name and signature, but there are **three breaking surfaces** — `start()` (now an options object), the **action interceptor** (now per-action `interceptAction` + `InterceptResult`), and presentation **display mode** (`isFullscreen` → `TransitionType`) — plus several renamed/removed methods. There is **no v5 source-compatibility shim**: renamed methods were renamed, not aliased, and removed ones no longer resolve. 1. Detect the Cordova project: a `config.xml` plus a `package.json` that lists `@purchasely/cordova-plugin-purchasely` (and optionally `@purchasely/cordova-plugin-purchasely-google`). The JavaScript call sites under `www/` (or `src/`) drive the rewrite; the native hosts are generated under `platforms/ios` and `platforms/android`. Do **not** treat a React Native or Flutter project as Cordova. -2. Read `../../references/cordova/migration-v6.md` — the authoritative Cordova v5.x → v6.0.0-rc.1 checklist and old→new API mapping — and skim `../../references/cordova/v5-api-reference.md` so you recognize every legacy symbol. Use them as the source of truth for every rewrite below. +2. Read `../../references/cordova/migration-v6.md` — the authoritative Cordova v5.x → v6 checklist and old→new API mapping (target `6.0.0-rc.3`, npm dist-tag `next`) — and skim `../../references/cordova/v5-api-reference.md` so you recognize every legacy symbol. Use them as the source of truth for every rewrite below. 3. Find current v5 Purchasely usages with ripgrep (these are the changed symbols to replace): a positional `Purchasely.start('` (string apiKey), `setPaywallActionInterceptor`, `onProcessAction`, `PaywallAction`, `RunningMode.paywallObserver`, `RunningMode.transactionOnly`, `isFullscreen`, `presentSubscriptions`, `presentProductWithIdentifier`, `presentPlanWithIdentifier`, `showPresentation`, `hidePresentation`, `readyToOpenDeeplink`, `isDeeplinkHandled`, `Purchasely.handle(`, `setDefaultPresentationResultHandler`, `closePaywall`, `startWithAPIKey`. **Do NOT flag unchanged method-based APIs** (`fetchPresentation` / `fetchPresentationForPlacement`, `presentPresentation` / `presentPresentationForPlacement` — only their `isFullscreen` arg changed, `closePresentation`, `userLogin` / `userLogout`, `purchaseWithPlanVendorId`, `restoreAllProducts`, `userSubscriptions` / `userSubscriptionsHistory`, every `setUserAttributeWith*`, `setThemeMode`, `revokeDataProcessingConsent`, `addEventsListener`) — they keep the same name and signature in v6. -4. **Bump the plugin pins first.** Update both plugins **exactly** to `6.0.0-rc.1` (never floating — a pre-release does not resolve from a range): `cordova plugin add @purchasely/cordova-plugin-purchasely@6.0.0-rc.1` and, where Google Play is used, `cordova plugin add @purchasely/cordova-plugin-purchasely-google@6.0.0-rc.1`. Pin the same `6.0.0-rc.1` in `package.json`. The plugin pulls iOS `Purchasely 6.0.0-rc.2` and Android `io.purchasely:core 6.0.0-rc.2` from the public repos. A stray floating range can silently upgrade the native SDK. +4. **Bump the plugin pins first.** Update both plugins **exactly** to `6.0.0-rc.3` (never floating — a pre-release does not resolve from a range). The npm `latest` dist-tag still points to `5.7.3`; the v6 pre-release is published under the `next` dist-tag, so install an **explicit version**, not `@latest` or `@next` (which can move): `cordova plugin add @purchasely/cordova-plugin-purchasely@6.0.0-rc.3` and, where Google Play is used, `cordova plugin add @purchasely/cordova-plugin-purchasely-google@6.0.0-rc.3`. Pin the same `6.0.0-rc.3` in `package.json`. The plugin pulls native iOS/Android `6.0.0-rc.3` from the public repos. A stray floating range can silently upgrade the native SDK. 5. **Bump the host requirements.** Set Android `minSdkVersion = 23`, `compileSdkVersion = 36`, `targetSdkVersion = 35` (raise from the v5 `compileSdk 33`). iOS deployment target is `13.4`. There is **no video player plugin on Cordova**. 6. **Rewrite initialization — `start()` is now an options object (breaking) and the running mode changed.** The v5 positional `Purchasely.start(apiKey, stores, storeKit1, userId, logLevel, runningMode, success, error)` is replaced by **`Purchasely.start(options, success, error)`** where `options` is a single config object (`apiKey` required; plus `appUserId`, `logLevel`, `runningMode`, `stores`, `storeKit1` / `storekitVersion`, `allowDeeplink`, `allowCampaigns`, `deeplink`). **The default running mode is now Observer (was Full).** If the app relies on Purchasely to handle and validate purchases, set `runningMode: Purchasely.RunningMode.full`. `RunningMode` values are now name strings (`'observer'` / `'full'`) — map `Purchasely.RunningMode.paywallObserver` → `Purchasely.RunningMode.observer`; `transactionOnly` was removed. In Observer mode, presentations no longer auto-close after purchase/restore — close them yourself with `Purchasely.closePresentation()`. 7. **Action interceptor — now per-action (breaking).** `Purchasely.setPaywallActionInterceptor(callback)` + `Purchasely.onProcessAction(true/false)` were **removed**. Rewrite to per-action `Purchasely.interceptAction(kind, handler)` where `kind` is a `Purchasely.PresentationAction` value (the `PaywallAction` constant was renamed to `PresentationAction`) and `handler(info, parameters)` returns — or resolves to — a `Purchasely.InterceptResult` (`success` / `failed` / `notHandled`). Return `success` when the app handled the action, `notHandled` when the SDK should execute its default behavior, and `failed` when the app tried and failed. Handlers may return a `Promise` for async work. Remove handlers with `removeActionInterceptor(kind)` / `removeAllActionInterceptors()`. 8. **Presentation API — `isFullscreen` is now a display mode; some present methods were removed.** Keep `fetchPresentation` / `fetchPresentationForPlacement`, `presentPresentation` / `presentPresentationForPlacement`, and the presentation `type` guard (`'NORMAL'` / `'FALLBACK'` / `'DEACTIVATED'` / `'CLIENT'`). Replace the `isFullscreen` boolean argument with a **display mode**: a `Purchasely.TransitionType` string, a boolean (still accepted: `true` → `fullScreen`, `false` → `modal`), or a transition object for drawer/popin sizing. **Removed present methods** — `presentSubscriptions()`, `presentProductWithIdentifier()`, `presentPlanWithIdentifier()`, `showPresentation()`, `hidePresentation()`; migrate to placement/screen presentation and build subscription UI from `userSubscriptions()` / `userSubscriptionsHistory()`. New: `presentPresentationForDefault(...)`, `fetchPresentationForDefault(...)`, `backPresentation()`. Dismiss with `Purchasely.closePresentation()` — rename any v5 `Purchasely.closePaywall()` → `Purchasely.closePresentation()`. There is **no `closeAllScreens()` on the Cordova JS side** unless the app added a custom bridge. 9. **Deeplinks.** Rename `Purchasely.readyToOpenDeeplink(bool)` → `Purchasely.allowDeeplink(bool)` and `Purchasely.isDeeplinkHandled(url, s, e)` / `Purchasely.handle(...)` → `Purchasely.handleDeeplink(url, s, e)`. These were **renamed, not aliased** — the old names no longer resolve. v6 displays deeplinks/campaigns immediately by default (`allowDeeplink` defaults to true); pass `allowDeeplink(false)` to defer. 10. **Default dismiss handler.** Rename `Purchasely.setDefaultPresentationResultHandler(cb)` → `Purchasely.setDefaultPresentationDismissHandler(cb)`. The callback now receives a single rich outcome object; legacy `result` and `plan` fields are kept, and `purchaseResult`, `closeReason`, and `presentation` are added. -11. **`synchronize()` now reports completion.** `Purchasely.synchronize()` gains optional `(success, error)` callbacks and resolves on completion (was fire-and-forget). The no-argument `Purchasely.synchronize()` still works — pass callbacks when you need to sequence receipt upload before chaining a subscriber-targeted presentation. +11. **`synchronize()` now reports completion.** `Purchasely.synchronize()` gains optional `(success, error)` callbacks and resolves on completion (was fire-and-forget). The no-argument `Purchasely.synchronize()` still works — pass callbacks when you need to sequence receipt upload before chaining a subscriber-targeted presentation. Note: resolving the `purchase`/`restore` interceptor with `Purchasely.InterceptResult.success` already triggers this synchronization automatically — a manual call inside that same interceptor is redundant, not required; keep manual `synchronize()` calls only for purchases made outside the interceptor. 12. **`presentSubscriptions()` was removed (breaking).** The native subscriptions-list UI was removed from both SDKs, so `Purchasely.presentSubscriptions()` no longer exists on the JS surface. Build your own subscriptions screen from `Purchasely.userSubscriptions()` / `Purchasely.userSubscriptionsHistory()` wherever the app relied on it. (`presentProductWithIdentifier()`, `presentPlanWithIdentifier()`, `showPresentation()`, and `hidePresentation()` were removed too — see step 8.) 13. **Verify before reporting completion:** build the Cordova app for the affected native target(s). For the example app, run: ```bash diff --git a/purchasely/skills/purchasely-review/SKILL.md b/purchasely/skills/purchasely-review/SKILL.md index becffa2..f23e305 100644 --- a/purchasely/skills/purchasely-review/SKILL.md +++ b/purchasely/skills/purchasely-review/SKILL.md @@ -30,7 +30,7 @@ The bundled references are intentionally curated, not a full copy of the public - `../../references/concepts/subscription-checks.md` — gating + restore purchases - `../../references/concepts/subscription-management.md` — native Manage Subscription entry point (App Store / Play) - `../../references/concepts/promotional-offers.md` — offers eligibility responsibility + implementation -- `../../references/concepts/campaigns.md` — deeplink/campaign display flags (`allowDeeplink` / `allowCampaigns` on v6; default `true` on native/Flutter/Cordova, React Native `allowDeeplink` default `false`) + SDK ≥ 5.1.0 +- `../../references/concepts/campaigns.md` — deeplink/campaign display flags (`allowDeeplink` / `allowCampaigns` on v6; `allowDeeplink` defaults to `true` on **every** v6 platform including React Native — the RN builder just omits the key when unset; `allowCampaigns` defaults to `true` in v6 on iOS/Android/Flutter, was `false` in v5) + SDK ≥ 5.1.0 - `../../references/concepts/lottie-animations.md` — Lottie bridge/dependency checks for Screens with animations - `../../references/concepts/analytics-integration.md` — events forwarding + analytics wrapper recommendation - `../../references/sdk-versions.md` — latest stable versions (flag outdated pins) @@ -73,13 +73,13 @@ Before returning review findings, run a Purchasely expert checkpoint. If the har If that subagent is not available, do the checkpoint inline using the `purchasely-sdk-expert` guidance when available, or this fallback checklist: -- Confirm the SDK generation used for each finding: React Native uses v6 (`6.0.0-rc.2`); native iOS / native Android / Flutter use v6 (`6.0.0-rc.1`); Cordova uses v6 (`6.0.0-rc.1`, pulling native `6.0.0-rc.2`). +- Confirm the SDK generation used for each finding: native iOS uses v6 (`6.0.0`, stable GA); native Android uses v6 (`6.0.1`, stable GA — Android never had a `6.0.0` tag); Flutter uses v6 (`6.0.0`, pulling native iOS `6.0.0` + Android core `6.0.1`); React Native uses v6 (`6.0.0-rc.3`, npm `latest`); Cordova uses v6 (`6.0.0-rc.3`, npm dist-tag `next`, pulling native iOS/Android `6.0.0-rc.3`). - Confirm version findings compare against `../../references/sdk-versions.md`. - Confirm Full-vs-Observer findings account for the v6 default running mode change. - Confirm removed/deprecated API findings are platform-specific and account for Cordova v6's method-based JS surface (React Native is also on the v6 API). - Confirm presentation findings distinguish full-screen display from explicit embedded/nested rendering. - Confirm interceptor findings prove every branch resolves exactly once. -- Confirm Observer-mode findings include `synchronize()` and the correct platform dismissal API. +- Confirm Observer-mode findings correctly treat the post-`SUCCESS` synchronization as automatic (a manual `synchronize()` call inside the interceptor is redundant, not required) and use the correct platform dismissal API. - Confirm each finding cites file/line evidence and is not based on a guessed signature. Only keep findings that remain supported after this expert check, unless you explicitly document a reasoned disagreement. @@ -121,7 +121,7 @@ Search the entire codebase using these patterns to build a map of all Purchasely **Deeplink patterns:** - Native v6 + Flutter v6: `handleDeeplink` / `allowDeeplink` -- React Native v6: `Purchasely.handleDeeplink(` (renamed from v5 `isDeeplinkHandled`) / `.allowDeeplink(` (builder modifier, default `false`) +- React Native v6: `Purchasely.handleDeeplink(` (renamed from v5 `isDeeplinkHandled`) / `.allowDeeplink(` (builder modifier, default `true` — same as every platform; the builder just omits the key when unset) - v5 tokens (removed with no alias on all v6 SDKs — FLAG on native/Flutter/React Native/Cordova v6 code): `isDeeplinkHandled` / `readyToOpenDeeplink` - `setDefaultPresentationResultHandler` / `setDefaultPresentationDismissHandler` @@ -129,6 +129,7 @@ Search the entire codebase using these patterns to build a map of all Purchasely - `userLogin` / `userLogout` / `setUserAttribute` - `setAttribute` / `setAttributes` - `revokeDataProcessingConsent` / `clearBuiltInAttributes` +- `oneSignalPlayerId` (v5, removed with no alias — flag it) / `oneSignalExternalId` / `oneSignalUserId` **Import statements:** - `import Purchasely` / `@import Purchasely` @@ -166,24 +167,26 @@ For each item below, search the code, analyze the context, and report one of: - [ ] **Handles PLYPresentationType.FALLBACK** — When the type is `.fallback`, the paywall should still be displayed but the app should log a warning. WARNING if not handled. - [ ] **Handles PLYPresentationType.CLIENT** — When the type is `.client`, the app should display its own custom paywall. WARNING if not handled (acceptable if no custom paywall exists). - [ ] **onClose/dismiss callback implemented** — The close callback must be set so the app can dismiss the paywall view/controller. On Flutter v6 the `display([Transition])` future resolves at dismiss with a `PresentationOutcome`, and a loaded `Presentation` exposes `.close()` / `.back()` for programmatic dismissal (the builder also offers `.onCloseRequested` / `.onDismissed` callbacks) — there is no `closePresentation()` / `closeAllScreens()` in Flutter v6. On React Native v6 the `display(transition?)` promise resolves at dismiss with a `PLYPresentationOutcome`, and the held `PLYPresentationRequest` exposes `.close()` / `.back()` (builder also offers `.onCloseRequested()` / `.onDismissed()`) — there is no `closePresentation()` / `closeAllScreens()` in React Native v6. FAIL if missing (causes stuck paywalls). +- [ ] **Android `close()` scope** (native Android only) — `presentation.close()` **delegates to `Purchasely.closeAllScreens()`**: it dismisses every screen currently displayed, not just this presentation instance — there is no instance-scoped close on Android (iOS does close only the targeted presentation). WARNING if the code assumes a scoped close inside a Flow or stacked-presentation scenario (e.g. closing one step without tearing down the rest) — on Android that call closes everything. ### 3.3 Action Interceptor - [ ] **Interceptor is registered** — v6 uses **per-action** registration: `Purchasely.interceptAction(.login) { … }` (iOS) / `Purchasely.interceptAction { … }` (Android) / `Purchasely.interceptAction(PresentationActionKind.purchase, (info, payload) async { … })` (Flutter) / `Purchasely.interceptAction('purchase', async (info, payload) => …)` (React Native) / `Purchasely.interceptAction(Purchasely.PresentationAction.purchase, function (info, parameters) { … })` (Cordova) — one call per action kind you handle, typically right after init. The v5 single `setPaywallActionsInterceptor` / `setPaywallActionInterceptorCallback` / `setPaywallActionInterceptor` + `onProcessAction` is removed on v6 platforms — FAIL if it appears in native, Flutter v6, React Native v6, or Cordova v6 code. WARNING if no interceptor is registered at all and the app needs to handle login/navigate/observer purchases. - [ ] **Each handler returns an intercept result** (v6 — native `PLYInterceptResult`, Flutter/Cordova `InterceptResult`, React Native string) — every registered handler must return `success` (app handled it, chain advances), `failed` (app tried, failed, remaining actions skipped), or `notHandled` (SDK executes the action). On Flutter the handler is `async` and must `return InterceptResult.success` / `.failed` / `.notHandled`; on React Native the `async` handler must `return 'success'` / `'failed'` / `'notHandled'` (a string); on Cordova the handler must return or resolve `Purchasely.InterceptResult.success` / `.failed` / `.notHandled`. FAIL if a handler falls through without returning a result. - [ ] **LOGIN action handled** — On login, present the app's login flow. v6 (native + Flutter + React Native + Cordova, kind `login` / `PresentationActionKind.login` / `'login'` / `Purchasely.PresentationAction.login`): return `success` on success, `notHandled` to let the SDK proceed without login. FAIL if the login action is ignored when the app requires authentication. -- [ ] **PURCHASE action handled** — In **Full mode**, Purchasely handles purchases automatically and auto-closes the paywall (v6 native + Flutter + React Native + Cordova: return `notHandled` / `InterceptResult.notHandled` / `'notHandled'` / `Purchasely.InterceptResult.notHandled`). In **Observer mode**, the app must trigger its own purchase flow (native v6: return `.success` after `synchronize()`, then dismiss with `closeAllScreens()` after the interceptor resolves; **Flutter v6**: run billing → `await Purchasely.synchronize()` → `return InterceptResult.success`, then dismiss with `presentation.close()`; **React Native v6**: run billing → `await Purchasely.synchronize()` → `return 'success'`, then dismiss with `request.close()`; **Cordova v6**: run billing → `Purchasely.synchronize(success, error)` → resolve `Purchasely.InterceptResult.success`, then dismiss with `closePresentation()`). Observer mode does not auto-close; FAIL if the mode and handling are mismatched. Note: returning `notHandled` for `purchase`/`restore` in Observer mode is a no-op (logs a warning) — flag it. +- [ ] **PURCHASE action handled** — In **Full mode**, Purchasely handles purchases automatically and auto-closes the paywall (v6 native + Flutter + React Native + Cordova: return `notHandled` / `InterceptResult.notHandled` / `'notHandled'` / `Purchasely.InterceptResult.notHandled`). In **Observer mode**, the app must trigger its own purchase flow (native v6: run billing → return `.success`, then dismiss with `closeAllScreens()` after the interceptor resolves; **Flutter v6**: run billing → `return InterceptResult.success`, then dismiss with `presentation.close()`; **React Native v6**: run billing → `return 'success'`, then dismiss with `request.close()`; **Cordova v6**: run billing → resolve `Purchasely.InterceptResult.success`, then dismiss with `closePresentation()`). Returning `SUCCESS`/`success` already triggers synchronization automatically — do not require a manual `synchronize()` call inside the interceptor for this to work. Observer mode does not auto-close; FAIL if the mode and handling are mismatched. Note: returning `notHandled` for `purchase`/`restore` in Observer mode is a no-op (logs a warning) — flag it. - [ ] **RESTORE action handled** — Similar to purchase: Full mode auto-handles, Observer mode needs custom logic. WARNING if not explicitly handled. - [ ] **CLOSE action handled** — The close action must dismiss the paywall. v6 (native + Flutter + React Native + Cordova, kind `close` / `PresentationActionKind.close` / `'close'` / `Purchasely.PresentationAction.close`): return `notHandled` to let the SDK close, or `success` if the app closes it itself (Flutter: via `presentation.close()`; React Native: via `request.close()`; Cordova: via `closePresentation()`). FAIL if missing (users cannot close the paywall). - [ ] **No missing intercept result** — every branch (early return, error catch, switch default) MUST return or resolve exactly one result. This is the #1 most common stuck-paywall bug. FAIL if any code path can skip the result. - [ ] **No double-completion** — Returning/resolving once and then mutating state as if the handler were still pending is a logic error. WARNING if there's a risk of double signalling. +- [ ] **No stale rc-era Android import** — `import io.purchasely.ext.interceptAction` (and `removeActionInterceptor`) was only required before rc.3, when these were top-level extension functions; since rc.3 they are member functions of `Purchasely` and need no import at all. WARNING if the import is still present in the code — it's harmless dead code, not a bug; delete it. - [ ] **No attempt to override post-purchase flow from the interceptor** — If the app holds the interceptor open, skips `proceed`, or calls `Purchasely.close()` manually to "stay on the paywall" / "show a custom thank-you screen" after a purchase, that's the wrong layer. The Composer button supports a **second action** (`purchase + open_screen` / `purchase + open_placement` / `purchase + deeplink`) and the default is *close in Full mode, stay open in Observer mode*. WARNING — recommend wiring the second action in the Console (or BYOS if the next screen is custom). See `../../references/concepts/paywall-actions.md` § Chaining multiple actions. ### 3.4 Deeplinks - [ ] **Deeplink forwarded to the SDK** — the app must hand incoming URLs to the SDK. On **all v6 SDKs (native iOS/Android, Flutter, React Native, Cordova)** the method is `Purchasely.handleDeeplink(...)` and the v5 `isDeeplinkHandled(...)` is **removed** with no alias — **FAIL** if it's still used (it won't compile / the symbol no longer exists). On **iOS** the SDK does NOT auto-intercept, so the deeplink must be wired from AppDelegate/SceneDelegate. On **Android v6** deeplinks are auto-intercepted (zero code) by reading the foreground activity intent; if the activity is `singleTask`/`singleTop`, verify `setIntent(intent)` is called in `onNewIntent` (otherwise the URI is hidden) or that a manual `handleDeeplink(uri, activity)` call exists. On **Flutter / React Native / Cordova v6**, forward incoming links from the app's deeplink handling to `Purchasely.handleDeeplink(...)` when the bridge does not receive them automatically. SKIP if the app doesn't support deeplinks. -- [ ] **allowDeeplink enabled** — v6 renamed the v5 `readyToOpenDeeplink` → `allowDeeplink`. On native/Flutter/Cordova it defaults to **true**, so usually no action is needed; on **React Native** it defaults to **false**, so the app **must** call `.allowDeeplink(true)` on the `Purchasely.builder(...)` chain if it relies on campaign/deeplink display — WARNING (FAIL with campaigns) if RN relies on deeplink display but never sets it. On **all v6 SDKs** the v5 `readyToOpenDeeplink` is **removed** with no alias — **FAIL** if it's used as the primary call in v6 code (it no longer exists). -- [ ] **Default presentation dismiss handler configured** — A default result/dismiss handler should be set so deeplink/campaign-triggered paywalls can report their outcome: native/Flutter `setDefaultPresentationResultHandler`; **React Native v6** `Purchasely.setDefaultPresentationDismissHandler((outcome) => …)` (returns a subscription with `.remove()`; one active handler, re-register replaces). WARNING if missing. +- [ ] **allowDeeplink enabled** — v6 renamed the v5 `readyToOpenDeeplink` → `allowDeeplink`. It defaults to **true** on **every** v6 platform, including React Native — there is no RN-specific exception; the RN builder simply omits the key when `.allowDeeplink(...)` isn't called and the native default applies. Only flag it if the app explicitly sets `allowDeeplink(false)` while relying on deeplink/campaign display. On **all v6 SDKs** the v5 `readyToOpenDeeplink` is **removed** with no alias — **FAIL** if it's used as the primary call in v6 code (it no longer exists). +- [ ] **Default presentation dismiss handler configured** — A default dismiss handler should be set so deeplink/campaign-triggered paywalls can report their outcome: native + Flutter + **React Native v6** all use `Purchasely.setDefaultPresentationDismissHandler((outcome) => …)` (Android has always used this name — `setDefaultPresentationResultHandler` never existed there; iOS renamed to it in v6). On React Native it returns a subscription with `.remove()`; one active handler, re-register replaces. WARNING if missing. ### 3.5 User Management @@ -192,8 +195,9 @@ For each item below, search the code, analyze the context, and report one of: - [ ] **userLogout() called on sign out** — `Purchasely.userLogout()` must be called when the user signs out. WARNING if missing (stale user data). - [ ] **Foreground resync** — `Purchasely.synchronize()` should be called from `applicationDidBecomeActive` (iOS), `ProcessLifecycleOwner` `ON_START` (Android), `AppState 'active'` (RN), `didChangeAppLifecycleState(.resumed)` (Flutter), or the `resume` event (Cordova). WARNING if missing — renewals or cancellations that happen while the app is backgrounded won't reflect in the client until the user re-opens. SKIP if running in Full mode AND the user never backgrounds the app for >1 day. Note (Flutter v6 + React Native v6): `synchronize()` is no longer fire-and-forget — it now **resolves on completion** (`Promise` on RN, `Future` on Flutter) and **rejects/throws on failure**, so `await` it (optionally wrap in `try/catch`) before chaining a follow-up presentation that targets subscribers. - [ ] **User attributes set** — If the app uses audience targeting, `setUserAttribute` should be called with relevant attributes. SKIP if audience targeting is not used. +- [ ] **No removed `oneSignalPlayerId` attribute** — `PLYAttribute.oneSignalPlayerId` is **removed with no alias**; the v6 replacement is `.oneSignalExternalId` / `.oneSignalUserId`. FAIL if the old call site is still used. The backend audience key also changed (`onesignal_player_id` → `onesignal_external_id`) — any Console audience rule still keyed on the old value **stops receiving data silently** (no error) after the app updates. WARNING to also audit Console audience rules, not just the SDK call site. - [ ] **Restore Purchases entry point** — Apple **requires** a Restore button reachable outside the paywall (Settings / Account) for App Store review. CRITICAL: **check the Purchasely paywall first** — if the Console operator has enabled the in-paywall Restore button on every relevant screen, an app-side button is duplicate work. If neither the paywall nor an app-side button exists, FAIL (App Store rejection risk). If only an app-side button exists but the paywall could also expose one, WARNING — confirm with the user / Console operator. See `../../references/concepts/subscription-checks.md`. -- [ ] **Manage Subscription entry point** — both stores require an in-app link to native subscription management. WARNING if missing from Settings / Account. See `../../references/concepts/subscription-management.md`. Note (v6 — native, Flutter and React Native): the built-in subscriptions screen was **removed**. `Purchasely.presentSubscriptions()` is **removed entirely** from Flutter v6 **and React Native v6** (it is NOT a no-op — the method no longer exists) — FAIL if it still appears in Flutter v6 or React Native v6 code; build your own screen from `userSubscriptions()` / `userSubscriptionsHistory()`. `Purchasely.displaySubscriptionCancellationInstruction()` is kept for source-compat but is a **no-op** on the native platforms. +- [ ] **Manage Subscription entry point** — both stores require an in-app link to native subscription management. WARNING if missing from Settings / Account. See `../../references/concepts/subscription-management.md`. Note (v6 — native, Flutter and React Native): the built-in subscriptions-list screen was **removed**. iOS never had a `presentSubscriptions()` method — the real iOS removals are `Purchasely.showController(_:type:from:)`, `PLYUIControllerType`, the legacy `PLYSubscriptionViewController` ("My Subscriptions"), and `PLYEvent.subscriptionsListViewed` / `.cancellationReasonPublished` — FAIL if any still appear. Android's equivalent removal is `Purchasely.subscriptionsFragment()` / `PLYSubscriptionsFragment` and the `ply/subscriptions` / `ply/cancellation_survey` deeplinks. `Purchasely.presentSubscriptions()` is **removed entirely** from Flutter v6 **and React Native v6** (it is NOT a no-op — the method no longer exists) — FAIL if it still appears in Flutter v6 or React Native v6 code; build your own screen from `userSubscriptions()` / `userSubscriptionsHistory()`. `Purchasely.displaySubscriptionCancellationInstruction()` is kept for source-compat as a **no-op on iOS only** — on **Android it was already undocumented upstream and is not present at all** (flag it as a dead symbol, not a harmless no-op), and on **Flutter it is removed too, not a no-op**. ### 3.6 Architecture (If a wrapper class exists) @@ -209,11 +213,11 @@ See `../../references/architecture-patterns.md` for recommended patterns and imp ### 3.7 Production Readiness -- [ ] **SDK version is current** — Compare the pinned version against `../../references/sdk-versions.md` (**React Native** on **6.0.0-rc.2**; native iOS and Android and **Flutter** on **6.0.0-rc.1**; **Cordova** on **6.0.0-rc.1**, pulling native `6.0.0-rc.2`). FAIL if older than minimum, WARNING if not at latest. FAIL if a floating version (`5.+`, `6.+`, `^5.0.0`, `~> 6.0`, `from:`/Up-to-Next-Major, `^6.0.0-rc.2`, etc.) is used instead of an exact pin. Because these v6 versions are pre-releases, they must be pinned exactly on every layer — iOS CocoaPods `pod 'Purchasely', '6.0.0-rc.1'`, SPM `exact: "6.0.0-rc.1"`, Carthage `== 6.0.0-rc.1`; Android `io.purchasely:core:6.0.0-rc.1`; **Flutter** `purchasely_flutter: 6.0.0-rc.1` (and `purchasely_google` / `purchasely_android_player` at the same `6.0.0-rc.1`, never `^`/`>=`); **React Native** `react-native-purchasely: 6.0.0-rc.2` (and `@purchasely/react-native-purchasely-google` / `-android-player` / `-amazon` / `-huawei` at the same `6.0.0-rc.2`, never `^`/`>=`); **Cordova** `@purchasely/cordova-plugin-purchasely: 6.0.0-rc.1` (and store/player companion packages at the same `6.0.0-rc.1`, never `^`/`>=`). -- [ ] **Plugin packages aligned** (cross-platform only) — All `@purchasely/cordova-plugin-*` packages MUST be the same `6.0.0-rc.1`; on **Flutter** all `purchasely_*` packages (`purchasely_flutter`, `purchasely_google`, `purchasely_android_player`) MUST be the same `6.0.0-rc.1`; on **React Native** all `react-native-purchasely*` packages (`react-native-purchasely`, `@purchasely/react-native-purchasely-google` / `-android-player` / `-amazon` / `-huawei`) MUST be the same `6.0.0-rc.2`. FAIL if mismatched. +- [ ] **SDK version is current** — Compare the pinned version against `../../references/sdk-versions.md` (**iOS (native)** on **6.0.0**, stable GA; **Android (native)** on **6.0.1**, stable GA — Android never had a `6.0.0` tag, the line went rc.1 → rc.2 → rc.3 → `6.0.1`; **Flutter** on **6.0.0**, pulling native iOS `6.0.0` + Android core `6.0.1`; **React Native** on **6.0.0-rc.3** (npm `latest`); **Cordova** on **6.0.0-rc.3** (npm dist-tag `next` — `latest` is still `5.7.3`), pulling native iOS/Android `6.0.0-rc.3`). FAIL if older than minimum, WARNING if not at latest. FAIL if a floating version (`5.+`, `6.+`, `^5.0.0`, `^6.0.0-rc.3`, etc.) is used on Android, Flutter, React Native, or Cordova instead of an exact pin — React Native and Cordova are still pre-releases, so a caret/range won't even resolve one. **iOS is the exception**: it's stable GA, so a minor-range pin is the recommended and expected form — CocoaPods `pod 'Purchasely', '~> 6.0'`, SPM `from: "6.0.0"` (Up to Next Major) — do not flag these as unpinned; only flag an iOS pin below `6.0.0` or an unbounded range. Exact pins elsewhere: Android `io.purchasely:core:6.0.1`; **Flutter** `purchasely_flutter: 6.0.0` (and `purchasely_google` / `purchasely_android_player` at the same `6.0.0`, never `^`/`>=`); **React Native** `react-native-purchasely: 6.0.0-rc.3` (and `@purchasely/react-native-purchasely-google` / `-android-player` / `-amazon` / `-huawei` at the same `6.0.0-rc.3`, never `^`/`>=`); **Cordova** `@purchasely/cordova-plugin-purchasely: 6.0.0-rc.3` (and store/player companion packages at the same `6.0.0-rc.3`, never `^`/`>=`, installed explicitly since npm `latest` still points to `5.7.3`). +- [ ] **Plugin packages aligned** (cross-platform only) — All `@purchasely/cordova-plugin-*` packages MUST be the same `6.0.0-rc.3`; on **Flutter** all `purchasely_*` packages (`purchasely_flutter`, `purchasely_google`, `purchasely_android_player`) MUST be the same `6.0.0`; on **React Native** all `react-native-purchasely*` packages (`react-native-purchasely`, `@purchasely/react-native-purchasely-google` / `-android-player` / `-amazon` / `-huawei`) MUST be the same `6.0.0-rc.3`. FAIL if mismatched. - [ ] **ProGuard/R8 rules added** (Android only) — `proguard-rules.pro` must include Purchasely keep rules or the dependency must use `consumerProguardFiles`. WARNING if missing. -- [ ] **No removed / deprecated APIs** — Native v6 **removed**: `setPaywallActionsInterceptor`, `fetchPresentation` (native), `presentationView(for:)`/`presentationViewControllerFor`/`presentationController`, `subscriptionsFragment()` and all `PLYSubscriptions*`/`PLYSubscriptionCancellation*` UI, `purchaseHistory()` (→ `userSubscriptionsHistory()`), `isPastSubscriber()`, the `intro*`/`introductory*` plan methods and `PLYPlanTags.INTRO_PRICE`/`TRIAL_PRICE` (→ `offer*` / `PLYPlanTags.OFFER_PRICE`), `PLYPresentationInfo` (→ `PLYInterceptorInfo`), the v5 deeplink methods `readyToOpenDeeplink` (→ `allowDeeplink`) and `isDeeplinkHandled` (→ `handleDeeplink`) — both removed with **no alias** on native iOS **and** Android — and — **Android only** — `PLYPresentationActionParameters` (Android replaced it with typed action subclasses; **iOS retains** `PLYPresentationActionParameters` as the interceptor `params`, so do not flag it on iOS). **FAIL** for each occurrence in native code (it won't compile against v6). Native v6 **deprecated** (removal v7): pre-`start` class funcs like `setEnvironment`/`setThemeMode` (→ builder modifiers) — WARNING for each. - - **Flutter v6 removed** (FAIL — the Dart symbol no longer exists): `Purchasely.start(...)` (→ `PurchaselyBuilder.apiKey(...).…start()`), `fetchPresentation` / `presentPresentation` / `presentPresentationForPlacement` / `presentPresentationWithIdentifier` / `presentProductWithIdentifier` / `presentPlanWithIdentifier` / `getPresentationView` (→ `PresentationBuilder` + `PresentationRequest` / `PLYPresentationView`), `closePresentation()` / `hidePresentation()` / `showPresentation()` / `closeAllScreens()` (→ `presentation.close()` / `.display()` / `.back()`), `setPaywallActionInterceptorCallback` + `onProcessAction` (→ `Purchasely.interceptAction(kind, handler)` returning `InterceptResult`), `presentSubscriptions()` (no replacement — build your own from `userSubscriptions()` / `userSubscriptionsHistory()`), and the v5 deeplink methods `readyToOpenDeeplink` (→ `allowDeeplink`) / `isDeeplinkHandled` (→ `handleDeeplink`) — **removed with no alias** (matching native iOS/Android and React Native). **Flutter v6 deprecated aliases** (WARNING): the `intro*` plan fields (→ `offer*`). `displaySubscriptionCancellationInstruction()` is kept but is a **no-op**. +- [ ] **No removed / deprecated APIs** — Native v6 **removed**: `setPaywallActionsInterceptor`, `fetchPresentation` (native), `presentationView(for:)`/`presentationViewControllerFor`/`presentationController`, `purchaseHistory()` (→ `userSubscriptionsHistory()`), `isPastSubscriber()`, the `intro*`/`introductory*` plan methods and `PLYPlanTags.INTRO_PRICE`/`TRIAL_PRICE` (→ `offer*` / `PLYPlanTags.OFFER_PRICE`), `PLYPresentationInfo` (→ `PLYInterceptorInfo`), the v5 deeplink methods `readyToOpenDeeplink` (→ `allowDeeplink`) and `isDeeplinkHandled` (→ `handleDeeplink`) — both removed with **no alias** on native iOS **and** Android — and — **Android only** — `PLYPresentationActionParameters` (Android replaced it with typed action subclasses; **iOS retains** `PLYPresentationActionParameters` as the interceptor `params`, so do not flag it on iOS). The removed subscriptions-list UI is **platform-specific, not a shared symbol**: on **Android** it's `Purchasely.subscriptionsFragment()` / `PLYSubscriptionsFragment` and the `ply/subscriptions` / `ply/cancellation_survey` deeplinks; on **iOS** it's `Purchasely.showController(_:type:from:)` / `PLYUIControllerType` / the legacy `PLYSubscriptionViewController` ("My Subscriptions") / `PLYEvent.subscriptionsListViewed` / `.cancellationReasonPublished` — iOS never had a `presentSubscriptions()` or `subscriptionsFragment()` method, don't flag those names as iOS removals. **FAIL** for each occurrence in native code (it won't compile against v6). Native v6 **deprecated** (removal v7): pre-`start` class funcs like `setEnvironment`/`setThemeMode` (→ builder modifiers) — WARNING for each. + - **Flutter v6 removed** (FAIL — the Dart symbol no longer exists): `Purchasely.start(...)` (→ `PurchaselyBuilder.apiKey(...).…start()`), `fetchPresentation` / `presentPresentation` / `presentPresentationForPlacement` / `presentPresentationWithIdentifier` / `presentProductWithIdentifier` / `presentPlanWithIdentifier` / `getPresentationView` (→ `PresentationBuilder` + `PresentationRequest` / `PLYPresentationView`), `closePresentation()` / `hidePresentation()` / `showPresentation()` / `closeAllScreens()` (→ `presentation.close()` / `.display()` / `.back()`), `setPaywallActionInterceptorCallback` + `onProcessAction` (→ `Purchasely.interceptAction(kind, handler)` returning `InterceptResult`), `presentSubscriptions()` (no replacement — build your own from `userSubscriptions()` / `userSubscriptionsHistory()`), and the v5 deeplink methods `readyToOpenDeeplink` (→ `allowDeeplink`) / `isDeeplinkHandled` (→ `handleDeeplink`) — **removed with no alias** (matching native iOS/Android and React Native). **Flutter v6 deprecated aliases** (WARNING): the `intro*` plan fields (→ `offer*`). `displaySubscriptionCancellationInstruction()` is **removed on Flutter — not a no-op** (the underlying Android method it wrapped was already undocumented upstream) — FAIL if still called; build a custom UI from `userSubscriptions()` / `userSubscriptionsHistory()`. - **React Native v6 removed** (FAIL — the JS symbol no longer exists): the whole v5 paywall API — `Purchasely.start({...})` / `startWithAPIKey` (→ `Purchasely.builder('key')….start()`), `fetchPresentation` / `presentPresentation` / `presentPresentationForPlacement` / `presentPresentationWithIdentifier` / `presentProductWithIdentifier` / `presentPlanWithIdentifier` (→ `Purchasely.presentation.placement(id)` / `.screen(id)` / `.defaultSource()` + `PLYPresentationRequest`), `showPresentation()` / `hidePresentation()` / `closePresentation()` (→ `request.display()` / `request.close()` / `request.back()`), `setPaywallActionInterceptorCallback` + `onProcessAction` (→ `Purchasely.interceptAction(kind, handler)` returning the string `'success' | 'failed' | 'notHandled'`), `setDefaultPresentationResultCallback` / `setDefaultPresentationResultHandler` (→ `setDefaultPresentationDismissHandler`), `readyToOpenDeeplink` (→ the builder modifier `.allowDeeplink(true)`), **`isDeeplinkHandled(uri)` (→ `handleDeeplink(uri)` — renamed, no alias; FLAG it)**, `presentSubscriptions()` / `displaySubscriptionCancellationInstruction()` / `clientPresentationDisplayed` / `clientPresentationClosed` (**no replacement** — build your own from `userSubscriptions()` / `userSubscriptionsHistory()`; they are NOT no-ops, the methods no longer exist), the `PLYPaywallAction` enum, and the `RunningMode.TRANSACTION_ONLY` / `PAYWALL_OBSERVER` values. `synchronize()` is now awaitable (`Promise`). `closeReason` values are `'button' | 'backSystem' | 'programmatic'` (no `interactiveDismiss`). The embedded `PLYPresentationView` component remains (now also accepts a preloaded `request` prop). - **Cordova v6 removed** (FAIL — the JS symbol no longer exists): positional `Purchasely.start('API_KEY', ...)` (→ `Purchasely.start(options, success, error)`), `setPaywallActionInterceptor` + `onProcessAction` (→ per-action `Purchasely.interceptAction(kind, handler)` returning/resolving `Purchasely.InterceptResult`), `PaywallAction` (→ `PresentationAction`), `readyToOpenDeeplink` (→ `allowDeeplink`), `isDeeplinkHandled` (→ `handleDeeplink`), `setDefaultPresentationResultHandler` (→ `setDefaultPresentationDismissHandler`), `presentSubscriptions()`, `presentProductWithIdentifier()`, `presentPlanWithIdentifier()`, `showPresentation()`, and `hidePresentation()`. - [ ] **Error handling around presentation build** — The presentation can fail (network error, invalid placement). The error/failure case must be handled gracefully: native v6 `.preload { loaded, error -> }` / `.preload()` throwing; **Flutter v6** the `display([Transition])` future's `PresentationOutcome.error` (and `try/catch` around `.preload()` / `.display()`); **React Native v6** the `display(transition?)` promise's `PLYPresentationOutcome.error` (and `try/catch` around `.preload()` / `.display()`); Cordova v6 the `fetchPresentation` / `presentPresentation` error callbacks. FAIL if errors are silently ignored. Map known `PLYError` cases (see `../../references/troubleshooting/error-codes.md`) when surfacing failure to the user. @@ -224,7 +228,8 @@ See `../../references/architecture-patterns.md` for recommended patterns and imp ### 3.8 Observer Mode Post-Purchase (if Observer mode is detected) -- [ ] **Correct ordering** — Native iOS/Android v6: inside the `.purchase` interceptor, run billing → `synchronize()` → **return `PLYInterceptResult.SUCCESS`** (there is no `proceed`/`processAction` callback), then **dismiss with `Purchasely.closeAllScreens()`** from your billing-result handler **after** the interceptor has resolved. **Flutter v6**: inside the `PresentationActionKind.purchase` handler, run billing → `await Purchasely.synchronize()` → **`return InterceptResult.success`**, then dismiss with **`presentation.close()`** after the handler resolves. **React Native v6**: inside the `'purchase'` handler, run billing → `await Purchasely.synchronize()` → **`return 'success'`**, then dismiss with **`request.close()`** on the held `PLYPresentationRequest` after the handler resolves. **Cordova v6**: inside the `PresentationAction.purchase` handler, run billing → `Purchasely.synchronize(success, error)` → resolve **`Purchasely.InterceptResult.success`**, then dismiss with **`closePresentation()`** after the handler resolves. Observer mode does **not** auto-close after a purchase/restore (the implicit `close_all` is Full-only) — the dismissal is the app's job unless a `close` / `close_all` action is wired on the button in the Console. Do **not** call the dismiss inside the interceptor/handler closure before returning the result — that races the SDK (WARNING). See `../../references/concepts/observer-mode-post-purchase.md`. +- [ ] **Correct ordering** — Native iOS/Android v6: inside the `.purchase` interceptor, run billing → **return `PLYInterceptResult.SUCCESS`** (there is no `proceed`/`processAction` callback — returning `SUCCESS` already triggers synchronization automatically), then **dismiss with `Purchasely.closeAllScreens()`** from your billing-result handler **after** the interceptor has resolved. **Flutter v6**: inside the `PresentationActionKind.purchase` handler, run billing → **`return InterceptResult.success`** (auto-triggers synchronization), then dismiss with **`presentation.close()`** after the handler resolves. **React Native v6**: inside the `'purchase'` handler, run billing → **`return 'success'`** (auto-triggers synchronization), then dismiss with **`request.close()`** on the held `PLYPresentationRequest` after the handler resolves. **Cordova v6**: inside the `PresentationAction.purchase` handler, run billing → resolve **`Purchasely.InterceptResult.success`** (auto-triggers synchronization), then dismiss with **`closePresentation()`** after the handler resolves. Observer mode does **not** auto-close after a purchase/restore (the implicit `close_all` is Full-only) — the dismissal is the app's job unless a `close` / `close_all` action is wired on the button in the Console. Do **not** call the dismiss inside the interceptor/handler closure before returning the result — that races the SDK (WARNING). See `../../references/concepts/observer-mode-post-purchase.md`. +- [ ] **Redundant manual `synchronize()` inside the interceptor** — Returning `SUCCESS` / `success` from the `purchase`/`restore` interceptor already triggers Purchasely's synchronization automatically. If the code still calls `Purchasely.synchronize()` (or awaits it) inside that same interceptor before returning, it is not incorrect — just unnecessary extra work. **WARNING, not FAIL.** Manual `synchronize()` remains legitimate for purchases made **outside** the interceptor path (a custom sell screen, BYOS) or to force a resync elsewhere in the app. - [ ] **Correct dismiss API** — native iOS/Android v6 should resolve the interceptor with a successful `PLYInterceptResult` and then dismiss with `closeAllScreens()` (not `closeDisplayedPresentation()`, which was renamed), since Observer mode does not auto-close. **Flutter v6** should resolve the handler with `InterceptResult.success` and then dismiss the loaded `Presentation` with `presentation.close()` — `closePresentation()` / `closeAllScreens()` no longer exist in Flutter v6. **React Native v6** should resolve the handler with `'success'` and then dismiss the held `PLYPresentationRequest` with `request.close()` — `closePresentation()` / `closeAllScreens()` no longer exist in React Native v6. **Cordova v6** should use `closePresentation()` unless the app added a custom native bridge. WARNING if the older or wrong-platform API is used, or if no dismissal happens in Observer mode and no Console `close` action is configured. - [ ] **iOS `@MainActor` wrap** (iOS only) — when calling `closeAllScreens()` from a non-isolated context (inside a `synchronize` callback or `DispatchQueue.main.async`), it must be wrapped in `Task { @MainActor in ... }`. FAIL if missing on iOS v6 (`closeAllScreens()` is `@MainActor`-isolated). @@ -233,8 +238,9 @@ See `../../references/architecture-patterns.md` for recommended patterns and imp SKIP this entire section if the project doesn't use Campaigns. To detect: ask the user, or check Purchasely Console → Campaigns. Otherwise: - [ ] **SDK ≥ 5.1.0** — minimum version required for Campaigns. FAIL if pinned below. -- [ ] **Deeplink display enabled** — trigger-based campaigns are delivered through deeplinks. v6 native / Flutter / Cordova: `allowDeeplink` defaults to **true**, so usually no action is needed; FAIL only if the app sets `allowDeeplink(false)`, and on Android verify the auto-interception isn't broken (e.g. `singleTask` activity missing `setIntent(intent)`). On Flutter/Cordova v6, also ensure incoming deeplinks reach `Purchasely.handleDeeplink(uri)` on iOS when the host app owns deeplink routing. **React Native v6**: `allowDeeplink` defaults to **false**, so `.allowDeeplink(true)` must be set on the `Purchasely.builder(...)` chain — FAIL if missing while campaigns are used; also forward incoming deeplinks to `Purchasely.handleDeeplink(uri)` (renamed from v5 `isDeeplinkHandled`) on iOS (Android auto-intercepts). +- [ ] **Deeplink display enabled** — trigger-based campaigns are delivered through deeplinks. `allowDeeplink` defaults to **true** on every v6 platform, including React Native (the builder just omits the key when `.allowDeeplink(...)` isn't called), so usually no action is needed; FAIL only if the app explicitly sets `allowDeeplink(false)` while campaigns are used, and on Android verify the auto-interception isn't broken (e.g. `singleTask` activity missing `setIntent(intent)`). On Flutter/Cordova/React Native v6, also ensure incoming deeplinks reach `Purchasely.handleDeeplink(uri)` on iOS when the host app owns deeplink routing (renamed from v5 `isDeeplinkHandled`; Android auto-intercepts). - [ ] **UI Handler keeps the returned presentation object** (if used) — refetching the presentation loses campaign context (audience match, screen variant, exposure tracking). WARNING if the handler refetches. +- [ ] **`allowCampaigns` default flip (v6)** — v6 defaults `allowCampaigns` to **true** on iOS/Android/Flutter (v5 default was `false`). If the client reports campaigns now firing that never appeared before a v6 upgrade, this default change is the explanation, not a regression — no fix needed unless the app wants to opt back out with an explicit `allowCampaigns(false)`. Campaign deeplink opening is additionally conditioned on the SDK being config-ready. ### 3.10 Promotional Offers (if promo offers / offer codes are used) diff --git a/purchasely/skills/purchasely-sdk-expert/SKILL.md b/purchasely/skills/purchasely-sdk-expert/SKILL.md index f4737b1..d1b2ef7 100644 --- a/purchasely/skills/purchasely-sdk-expert/SKILL.md +++ b/purchasely/skills/purchasely-sdk-expert/SKILL.md @@ -26,7 +26,7 @@ For workflow tasks, use the dedicated skills instead: ### SDK generation rules -- **Native iOS, native Android, Flutter, React Native, and Cordova use SDK v6** (React Native pins `6.0.0-rc.2`; Cordova pins `6.0.0-rc.1` and pulls native `6.0.0-rc.2`; native iOS / Android / Flutter pin `6.0.0-rc.1`). +- **Native iOS, native Android, Flutter, React Native, and Cordova use SDK v6** (native iOS is stable GA at `6.0.0`; native Android is stable GA at `6.0.1` — Android never had a `6.0.0` tag, the release line went rc.1 → rc.2 → rc.3 → `6.0.1`; Flutter pins `6.0.0`, pulling native iOS `6.0.0` + Android core `6.0.1`; React Native pins `6.0.0-rc.3` (npm `latest` tag; GA `6.0.0` in preparation); Cordova pins `6.0.0-rc.3` (npm dist-tag `next` — `latest` is still `5.7.3`, install the version explicitly) and pulls native iOS/Android `6.0.0-rc.3`). - Always answer iOS / Android / Flutter / React Native / Cordova with v6 APIs. - Never invent signatures. If exact syntax matters, load the matching reference file before answering. @@ -40,7 +40,7 @@ On native iOS, native Android, Flutter, React Native, and Cordova v6, the defaul - React Native: `.runningMode('full')` (string) - Cordova: `runningMode: Purchasely.RunningMode.full` in the `start` options object -Observer mode means the app owns billing and must call `Purchasely.synchronize()` after successful purchases. Native iOS/Android Observer presentations do not auto-close after purchase/restore; dismiss explicitly with `closeAllScreens()`. Flutter v6 dismisses via `presentation.close()`. React Native v6 dismisses via `request.close()`. Cordova v6 dismisses via `closePresentation()`. +Observer mode means the app owns billing. Returning `SUCCESS` from the purchase/restore interceptor already triggers Purchasely's synchronization automatically — do **not** call `Purchasely.synchronize()` manually inside the interceptor. Manual `synchronize()` is only needed for purchases made **outside** the interceptor (a custom sell screen, BYOS). Native iOS/Android Observer presentations do not auto-close after purchase/restore; dismiss explicitly with `closeAllScreens()`. Flutter v6 dismisses via `presentation.close()`. React Native v6 dismisses via `request.close()`. Cordova v6 dismisses via `closePresentation()`. ## Answering workflow @@ -74,11 +74,13 @@ Load as needed: - `../../references/concepts/subscription-management.md` — native subscription management pages - `../../references/concepts/promotional-offers.md` — Apple promos, Google offers, offer codes - `../../references/concepts/dynamic-offerings.md` — `setDynamicOffering` runtime plan/offer overrides (server-side at fetch); same-plan billing-type pitfall -- `../../references/concepts/monthly-commitment.md` — Apple advance commitment (12-month billed monthly), `PLYBillingPlanType`, iOS 26.4+ eligibility (excl. US/SG) +- `../../references/concepts/monthly-commitment.md` — Apple advance commitment (12-month billed monthly), `PLYBillingPlanType`, iOS 26.4+ eligibility (excl. US/SG), and Google Play native installment subscriptions - `../../references/concepts/campaigns.md` — trigger / placement campaigns - `../../references/concepts/byos.md` — Bring Your Own Screen, iOS/Android only - `../../references/concepts/lottie-animations.md` — Lottie weak dependency bridge - `../../references/concepts/analytics-integration.md` — forwarding SDK events +- `../../references/concepts/rendering-engine.md` — UIKit / Android Views rendering engine and gotchas +- `../../references/concepts/web-checkout.md` — Web Checkout action/flow - `../../references/architecture-patterns.md` — optional wrapper / gateway architecture ### Platform references @@ -111,6 +113,8 @@ Load the matching platform before giving exact setup or API signatures: - Cordova v6: `fetchPresentationForPlacement(...)` then `presentPresentation(..., displayMode, ...)`. - For Flows, prefer build/fetch → type guard → display. Avoid placement shorthand when Flow behavior matters. - For embedded / nested rendering, only use container APIs when the user explicitly wants to own the container. +- Android: `Purchasely.setDefaultPresentationDismissHandler(handler)` is, and always was, the correct Android name — `setDefaultPresentationResultHandler` never existed there (only iOS renamed *from* that name in v6). Since `6.0.1` the `handler` parameter is nullable — pass `null` to unregister it. +- Android: `presentation.close()` delegates to `Purchasely.closeAllScreens()` — there is no instance-scoped close on Android (unlike iOS, which closes only the targeted presentation); it dismisses every currently displayed screen. ### Interceptors @@ -120,16 +124,21 @@ Load the matching platform before giving exact setup or API signatures: - Every React Native v6 handler must return the string `'success' | 'failed' | 'notHandled'` on every path. - Every Cordova v6 handler must return or resolve `Purchasely.InterceptResult` on every path. - Missing completion freezes the paywall. +- Android: `interceptAction` / `removeActionInterceptor` are **member functions of `Purchasely`** since rc.3 (previously top-level extension functions requiring `import io.purchasely.ext.interceptAction`). No import is needed on current SDKs; a leftover import is harmless dead code, not a bug. +- Returning `SUCCESS` from the `purchase`/`restore` interceptor already triggers synchronization automatically in Observer mode — do not also call `synchronize()` inside the interceptor. Manual `synchronize()` is only for purchases made outside the interceptor (custom sell screen, BYOS). ### Removed / wrong APIs Do not generate these for v6 native, Flutter, React Native, or Cordova: - native `fetchPresentation`, `setPaywallActionsInterceptor`, `presentationView` / `presentationController` +- native iOS: `Purchasely.showController(_:type:from:)`, `PLYUIControllerType`, the legacy `PLYSubscriptionViewController` ("My Subscriptions" screen), `PLYEvent.subscriptionsListViewed` / `.cancellationReasonPublished` — iOS never had a `presentSubscriptions()` method, don't invent one +- native Android: `Purchasely.subscriptionsFragment()`, `PLYSubscriptionsFragment`, the `ply/subscriptions` and `ply/cancellation_survey` deeplinks - Flutter `Purchasely.start(...)`, `fetchPresentation`, `presentPresentation*`, `setPaywallActionInterceptorCallback`, `onProcessAction`, `closePresentation()`, `closeAllScreens()`, `presentSubscriptions()` - React Native `Purchasely.start({...})`, `fetchPresentation`, `presentPresentation*`, `setPaywallActionInterceptor`, `onProcessAction`, `closePresentation()`, `closeAllScreens()`, `presentSubscriptions()`, `readyToOpenDeeplink`, `isDeeplinkHandled`, `setDefaultPresentationResultCallback`/`Handler`. ⚠️ **`isDeeplinkHandled(uri)` was renamed to `Purchasely.handleDeeplink(uri)` on React Native** (removed with no alias, matching native iOS/Android and Flutter) — generate `handleDeeplink`, never `isDeeplinkHandled`. - Cordova positional `Purchasely.start('API_KEY', ...)`, `setPaywallActionInterceptor`, `onProcessAction`, `PaywallAction`, `readyToOpenDeeplink`, `isDeeplinkHandled`, `presentSubscriptions()`, `presentProductWithIdentifier()`, `presentPlanWithIdentifier()`, `showPresentation()`, `hidePresentation()` - Do not generate `purchase(planId:)`, `Purchasely.purchase({ planId })`, or generic `Purchasely.purchase(...)` +- `PLYAttribute.oneSignalPlayerId` — removed with no alias; use `.oneSignalExternalId` / `.oneSignalUserId`. The backend audience key also changed (`onesignal_player_id` → `onesignal_external_id`) — any audience rule still keyed on the old value stops receiving data silently. Use `purchaseWithPlanVendorId(...)` for React Native / Flutter / Cordova programmatic purchases; use native `PLYPlan` purchase APIs on iOS / Android. @@ -139,7 +148,8 @@ For any campaign / trigger / `APP_STARTED` / launch display question, load `../. - Trigger-based campaigns are SDK-managed. The app does not manually build or fetch the campaign paywall. - Placement-based campaigns override the placement when the app displays that placement. -- Mention deeplink display readiness: v6 native / Flutter / Cordova use `allowDeeplink` (default true); React Native v6 also uses `.allowDeeplink(true)` but it defaults to **false** (set it on the `Purchasely.builder(...)` chain). Cordova v6 also exposes `allowCampaigns` separately from `allowDeeplink`. +- Mention deeplink display readiness: v6 native / Flutter / React Native / Cordova all use `allowDeeplink`, and it defaults to **true** everywhere. On React Native the builder simply **omits** the key when `.allowDeeplink(...)` isn't called, and the native default (`true`) applies — there is no RN-specific exception. Cordova v6 also exposes `allowCampaigns` separately from `allowDeeplink`. +- **`allowCampaigns` default flip (v6):** defaults to **true** on iOS/Android/Flutter (v5 default was `false`). If a client migrating to v6 suddenly sees campaigns firing that never showed before, this default change is the cause, not a regression. Campaign deeplink opening is additionally conditioned on the SDK being config-ready. ### BYOS @@ -176,7 +186,7 @@ Use this checklist when another Purchasely workflow asks for expert validation a 3. Running mode is explicit when Purchasely must process purchases. 4. Presentation path matches the platform generation and handles `DEACTIVATED` / `FALLBACK` where relevant. 5. Interceptor completion is guaranteed on every branch. -6. Observer-mode purchases call `synchronize()` and use the correct dismissal API. +6. Observer-mode purchases that go through the interceptor rely on the SDK's automatic post-`SUCCESS` synchronization — `synchronize()` is only called manually for purchases made outside the interceptor (custom sell screen, BYOS) — and dismissal uses the correct platform API. 7. User identity (`userLogin`) and attributes are set before audience-dependent presentation loading. 8. Deeplinks / campaigns use the correct readiness and handling API for the platform. 9. Programmatic purchases use exact platform APIs, never invented `purchase(planId)` forms.