Skip to content

feat!: v6-only paywall API (remove v5) + single native module#243

Open
kherembourg wants to merge 68 commits into
mainfrom
feat/sdk-v6-migration
Open

feat!: v6-only paywall API (remove v5) + single native module#243
kherembourg wants to merge 68 commits into
mainfrom
feat/sdk-v6-migration

Conversation

@kherembourg

@kherembourg kherembourg commented May 28, 2026

Copy link
Copy Markdown
Contributor

Summary

Migrates the React Native SDK to the v6 paywall API as the single, only paywall surface, unifies the native layer into one module per platform, pins the now‑published native 6.0.0‑rc.2 SDKs, moves the example/tooling to React Native 0.86.0, and adds end‑to‑end test suites (T1–T20) on both Android and iOS with their own CI workflows.

Branch rebased onto main (up to date — includes the latest dependency/security bumps).

Paywall API — v6 only

  • v5 paywall API removed (not deprecated). Paywalls are displayed and intercepted exclusively through the builders: PurchaselyBuilder (start), PLYPresentationBuilderPLYPresentationRequest (preload / display / close / back / onDismissed) resolving to a 5‑field PLYPresentationOutcome, and typed interceptAction. No dual/soft‑transition mode.
  • setDefaultPresentationDismissHandler / removeDefaultPresentationDismissHandler added: a global handler that receives the PLYPresentationOutcome when a paywall opened outside an explicit display() (campaign, deeplink, Promoted IAP) is dismissed.
  • Client‑presentation tracking removed: clientPresentationDisplayed / clientPresentationClosed and the global close() are gone (superseded by request.close()).
  • Core SDK unchanged and version‑agnostic: user management, products, subscriptions, attributes, restore, event listeners, and the embedded PLYPresentationView all keep their existing API. New v6 aliases/capabilities were added on top (allowCampaigns, allowDeeplink, listenToEvents/listenToPurchases, setUserAttributeListener, typed setUserAttributeWithInt/Double(Array)).
  • Example app rewritten to the builder / PLYPresentationBuilder / interceptAction API.
  • Full public‑API delta (added / changed / removed) is documented in the pinned comment below and in MIGRATION-v6.md.

Single native module per platform

  • iOS — the v6 bridge category was merged into PurchaselyRN.m; PurchaselyRNV6.h/.m deleted. One Objective‑C bridge.
  • AndroidPurchaselyV6Bridge merged into PurchaselyModule.kt; the v6/ package is gone. One Kotlin module.
  • No "v6" naming left in the code (TypeScript src/v6/ dissolved into src/, bridge method/event names and log tags cleaned). v6 survives only as the SDK version and in the migration guide. Public API names are unchanged.

Native dependencies (published)

Pinned to the released 6.0.0‑rc.2 native SDKs — the native builds are no longer blocked:

  • iOS — pod Purchasely 6.0.0-rc.2.
  • Androidio.purchasely:core / google-play / amazon / huawei-services / player all 6.0.0-rc.2.

React Native 0.86.0

Example app and test‑project tooling migrated to RN 0.86.0 (New Architecture / bridgeless). CI iOS therefore requires Xcode ≥ 16.1 → the iOS E2E job runs on macos-15.

End‑to‑end tests (T1–T20)

A device‑driven E2E suite (example/src/E2ETestRunner.tsx, launched via E2E_MODE) exercises the real native bridge end‑to‑end:

  • T1–T13 — anonymous id, login/logout, preload, dynamic offerings, products, interceptor lifecycle, drawer display + programmatic close, purchase interceptor on a real button tap (T8), default dismiss handler + deeplink + back (T9), PRESENTATION_VIEWED / PRESENTATION_CLOSED events, base user attributes.
  • T14–T20 — extended attribute types (double / date / string+int+bool arrays), bulk attribute map + clear, increment/decrement counters, product catalog, dynamic offerings CRUD, screen/display variants, and config setters (allowDeeplink/allowCampaigns/setLanguage/setThemeMode/setLogLevel/setDebugMode/revokeDataProcessingConsent).

See integration_test/E2E_TEST_INDEX.md.

  • Android.github/workflows/e2e-android.yml: ubuntu-latest + KVM + x86_64 emulator (ReactiveCircus/android-emulator-runner), release APK (JS bundle embedded). PR + manual + nightly.
  • iOS.github/workflows/e2e-ios.yml: macos-15 simulator, Release build (JS bundle embedded, no Metro), markers captured via simctl launch --console, T8/T9 driven with idb (point coordinates). PR + manual + nightly.

Breaking changes

The v5 paywall methods are removed: start({...}), fetchPresentation, presentPresentation*, presentProductWithIdentifier, presentPlanWithIdentifier, show/hide/closePresentation, global close(), setPaywallActionInterceptor(Callback), onProcessAction, setDefaultPresentationResultCallback/Handler, readyToOpenDeeplink, presentSubscriptions, clientPresentationDisplayed/Closed, and isDeeplinkHandled (renamed handleDeeplink). Behaviour: runningMode now defaults to 'observer' (was effectively full) — a silent change apps must audit. See the pinned comment and MIGRATION-v6.md for the full old → new mapping. Core (user/products/subscriptions/attributes/listeners) is not affected.

Test plan

  • yarn typecheck / yarn lint — clean
  • yarn test — unit suite green (139 tests)
  • build-android (incl. :react-native-purchasely:testDebugUnitTest) / build-ios — green in CI
  • Android E2E T1–T20 — green in CI (e2e-android.yml)
  • iOS E2E T1–T20 — green in CI (e2e-ios.yml)

Reference

🤖 Generated with Claude Code

@greptile-apps

greptile-apps Bot commented May 28, 2026

Copy link
Copy Markdown

Greptile Summary

This PR migrates the React Native Purchasely SDK to a v6-only paywall API, merges the separate v6 native bridge modules into a single module per platform (iOS and Android), pins native SDKs to 6.0.0-rc.2, moves the example app to RN 0.86.0, and adds a 20-test E2E suite with CI workflows.

  • v5 paywall API removed: start({...}), fetchPresentation, presentPresentation*, setPaywallActionInterceptorCallback, and related methods are gone; replaced by PurchaselyBuilder, PLYPresentationBuilder/PLYPresentationRequest, and interceptAction.
  • Single native module: PurchaselyRNV6.h/.m (iOS) and PurchaselyV6Bridge (Android) dissolved into PurchaselyRN.m and PurchaselyModule.kt respectively; all event names and public API identifiers unchanged.
  • runningMode default changed to 'observer': A silent breaking change that apps migrating from v5 must explicitly audit before upgrading.

Confidence Score: 3/5

The iOS bridge has three cross-platform correctness issues introduced by this PR that affect production apps using the new v6 API on iOS devices.

The iOS displayPresentation: always discards a prior preload() result and re-fetches, negating the latency benefit and risking presentation divergence. closePresentation: emits CLOSE_REQUESTED on programmatic close, inverting the callback meaning vs Android. The Close/CloseAll interceptor always reports 'button' as the close reason regardless of the actual value.

packages/purchasely/ios/PurchaselyRN.m — all three issues are in the v6 display, close, and interceptor sections of this file.

Important Files Changed

Filename Overview
packages/purchasely/ios/PurchaselyRN.m Merged v6 bridge into main Obj-C module; three issues found: displayPresentation always re-fetches instead of reusing a preloaded request, closePresentation emits spurious CLOSE_REQUESTED on programmatic close, and Close/CloseAll interceptor hardcodes closeReason='button'.
packages/purchasely/android/src/main/java/com/reactnativepurchasely/PurchaselyModule.kt V6 bridge merged into single Kotlin module; correctly reuses preloaded presentations and applies 30s interceptor timeout. Minor: display callback and onDismissed property both call emitPresentationDismissed, contradicting the 'noop' comment.
packages/purchasely/src/presentation.ts New PLYPresentationBuilder/PLYPresentationRequest API with clean lifecycle event handling and teardown on dismiss. normalizePresentation duplicated vs interceptor.ts.
packages/purchasely/src/interceptor.ts New typed interceptAction API; correctly deregisters before re-registering. normalizePresentation duplicated from presentation.ts.
packages/purchasely/src/startBuilder.ts New PurchaselyBuilder cleanly maps string enums to native ordinals and replays cold-start deeplinks.
packages/purchasely/src/index.ts v5 paywall API removed and replaced with v6 builder pattern; core APIs unchanged.
packages/purchasely/src/presentationTypes.ts New cross-platform type contract for the v6 paywall API; well-typed with clear action payload union and outcome types.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant JS as TypeScript (JS)
    participant Native as Native Bridge
    participant SDK as Purchasely SDK

    JS->>Native: preloadPresentation(requestId, payload)
    Native->>SDK: PLYPresentationBuilder.build().preload()
    SDK-->>Native: LOADED(presentation)
    Native-->>JS: PURCHASELY_PRESENTATION_LOADED
    JS->>Native: displayPresentation(requestId, payload, transition)
    Native->>SDK: display(activity/vc, transition)
    SDK-->>Native: onPresented(presentation)
    Native-->>JS: PURCHASELY_PRESENTATION_PRESENTED
    SDK-->>Native: onDismissed(outcome)
    Native-->>JS: PURCHASELY_PRESENTATION_DISMISSED
    JS->>JS: resolve(PLYPresentationOutcome)
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant JS as TypeScript (JS)
    participant Native as Native Bridge
    participant SDK as Purchasely SDK

    JS->>Native: preloadPresentation(requestId, payload)
    Native->>SDK: PLYPresentationBuilder.build().preload()
    SDK-->>Native: LOADED(presentation)
    Native-->>JS: PURCHASELY_PRESENTATION_LOADED
    JS->>Native: displayPresentation(requestId, payload, transition)
    Native->>SDK: display(activity/vc, transition)
    SDK-->>Native: onPresented(presentation)
    Native-->>JS: PURCHASELY_PRESENTATION_PRESENTED
    SDK-->>Native: onDismissed(outcome)
    Native-->>JS: PURCHASELY_PRESENTATION_DISMISSED
    JS->>JS: resolve(PLYPresentationOutcome)
Loading

Fix All in Claude Code Fix All in Cursor Fix All in Codex

Prompt To Fix All With AI
Fix the following 5 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 5
packages/purchasely/ios/PurchaselyRN.m:1421-1443
**`closePresentation:` emits `CLOSE_REQUESTED` on programmatic close — wrong semantics on iOS**

`closePresentation:` emits `kPresentationEventCloseRequested` before calling `[presentation close]`. This means every call to `request.close()` from JS fires the `onCloseRequested` builder callback on iOS. Android's `closePresentation()` does NOT emit `EVENT_PRESENTATION_CLOSE_REQUESTED`; it simply calls `closeAllScreens()`. The result is that `onCloseRequested` has inverted semantics across platforms: on Android it fires when the paywall itself wants to close, while on iOS it fires for programmatic close only. An app that registers `onCloseRequested` to detect a user-tap on the paywall close button will receive a false positive on iOS whenever it calls `request.close()`.

### Issue 2 of 5
packages/purchasely/ios/PurchaselyRN.m:1199-1367
**`displayPresentation:` always re-fetches — preloaded presentation is discarded on iOS**

`displayPresentation:` always creates a fresh `PLYPresentationBuilder` and calls `[request preloadWithCompletion:]` from scratch, even when the same `requestId` was already preloaded by a preceding `preloadPresentation:` call. The `kPresentationsByRequest[requestId]` entry written by `preloadPresentation:` is never consulted here. In contrast, the Android bridge checks `activePresentationRequests[requestId]` first and reuses the already-built `PLYPresentationBase.Prepared`. Calling `preload()` followed by `display()` on iOS therefore performs two independent SDK fetches, defeating the purpose of preloading and introducing a window where a backend change could cause the displayed presentation to differ from the preloaded one.

### Issue 3 of 5
packages/purchasely/ios/PurchaselyRN.m:1556-1560
**iOS interceptor always reports `closeReason = "button"` — actual reason silently dropped**

For intercepted `close`/`closeAll` actions, the iOS bridge hardcodes `payloadOut[@"closeReason"] = @"button"`, while the Android bridge correctly maps the real reason via `closeReason.toRNString()`. If the close was triggered by a back-system gesture or programmatically, the interceptor payload on iOS always says `"button"`, giving the handler incorrect data.

```suggestion
                    case PLYPresentationActionClose:
                    case PLYPresentationActionCloseAll: {
                        if (params.closeReason != nil) {
                            payloadOut[@"closeReason"] = params.closeReason;
                        } else {
                            payloadOut[@"closeReason"] = @"button";
                        }
                        break;
                    }
```

### Issue 4 of 5
packages/purchasely/android/src/main/java/com/reactnativepurchasely/PurchaselyModule.kt:806-815
**`DISMISSED` event may fire twice if both `onDismissed` property and `callback` parameter fire**

The comment says "the local `callback` is a noop", but the code passes `callback = { outcome -> emitPresentationDismissed(requestId, outcome) }` — the same function wired via `wirePresentationCallbacks`' `prepared.onDismissed`. If the native SDK fires both callbacks, `DISMISSED` is emitted twice for the same `requestId`. The JS side is safe (subscriptions torn down after first), but the comment contradicts the code. Consider passing an actual noop or removing the duplicate `onDismissed` wiring.

### Issue 5 of 5
packages/purchasely/src/interceptor.ts:27-51
**`normalizePresentation` duplicated between `interceptor.ts` and `presentation.ts`**

The function is defined identically in both files. A diverging update to one copy that is not mirrored in the other will cause subtly different `PLYPresentation` shapes for preload/display vs. interceptor callbacks. Consider extracting it to a shared utility in `presentationTypes.ts`.

Reviews (4): Last reviewed commit: "docs(android): correct setUserAttributeW..." | Re-trigger Greptile

Comment thread .gitignore Outdated
Comment thread packages/purchasely/ios/PurchaselyRNV6.m Outdated
Comment thread packages/purchasely/ios/PurchaselyRNV6.m Outdated

Copy link
Copy Markdown
Contributor Author

All 5 Greptile findings addressed in fbc99b6 (CI green locally: yarn typecheck ✓, yarn lint ✓, yarn test 149/149 ✓):

# Sev Finding Resolution
1 P1 .gitignore merge corruption (.nx/workspace-datajest_dx/) Split back into .nx/workspace-data + jest_dx/.
2 P1 Android onDismissed fired twice on display error Removed the synthesized DISMISSED event; keep promise.reject() so the JS .catch settles once and still fires onPresented(null, error) — matching the iOS error path (parity preserved).
3 P1 iOS PresentationBuilder.default() always 400 v6ExtractTargetsFromPayload: now reads isDefault; v6Preload/v6Display route it to fetchPresentationWith:nil (legacy default-fetch path).
4 P1 iOS concurrent access to NSMutable* globals Added kV6StateLock; all reads/writes of the three shared collections guarded by @synchronized. Interceptor callback invoked outside the lock.
5 P2 Android close() dismisses every presentation Kept closeAllScreens() (no per-request close in native SDK yet; legacy closePresentation() does the same) + added a PLYLogger.w warning and an @remarks doc note on the public JS close().

Note on #2: I applied the inverse of the literal suggestion (drop event, keep reject rather than drop reject, keep event). Both eliminate the double-fire, but keeping the reject routes through the purpose-built JS .catch handler that also fires onPresented(null, error), keeping Android consistent with iOS. Details in the inline thread.


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

@greptileai review


Generated by Claude Code

@kherembourg

Copy link
Copy Markdown
Contributor Author

@greptileai review

Comment on lines +506 to +514
NSString *callbackId = [[NSUUID UUID] UUIDString];
@synchronized (kV6StateLock) {
kV6InterceptorCallbacks[callbackId] = ^(NSString *result) {
// Map InterceptResult → bool the native interceptor expects.
// - success / failed → JS handled the action: don't proceed natively.
// - notHandled → let the SDK perform its default behavior.
BOOL proceed = [result isEqualToString:@"notHandled"];
onProcessActionHandler(proceed);
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 iOS interceptor callbacks have no timeout — SDK action can hang indefinitely

The kV6InterceptorCallbacks dictionary stores each callbackId → onProcessActionHandler block with no expiry. If the JS side never calls v6CompleteInterceptor (e.g. the RN bridge is reloaded, the event listener is torn down, or the handler throws before completing), onProcessActionHandler(proceed) is never invoked and the Purchasely SDK action is frozen for the lifetime of the process. The Android bridge addressed this with withTimeoutOrNull(INTERCEPTOR_TIMEOUT_MS = 30_000L) that falls back to NOT_HANDLED. The iOS side needs an equivalent: a GCD dispatch_after (or an NSTimer) that fires after ~30 s, invokes the stored callback with "notHandled" and removes the entry from kV6InterceptorCallbacks.

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/purchasely/ios/PurchaselyRNV6.m
Line: 506-514

Comment:
**iOS interceptor callbacks have no timeout — SDK action can hang indefinitely**

The `kV6InterceptorCallbacks` dictionary stores each `callbackId → onProcessActionHandler` block with no expiry. If the JS side never calls `v6CompleteInterceptor` (e.g. the RN bridge is reloaded, the event listener is torn down, or the handler throws before completing), `onProcessActionHandler(proceed)` is never invoked and the Purchasely SDK action is frozen for the lifetime of the process. The Android bridge addressed this with `withTimeoutOrNull(INTERCEPTOR_TIMEOUT_MS = 30_000L)` that falls back to `NOT_HANDLED`. The iOS side needs an equivalent: a GCD `dispatch_after` (or an `NSTimer`) that fires after ~30 s, invokes the stored callback with `"notHandled"` and removes the entry from `kV6InterceptorCallbacks`.

How can I resolve this? If you propose a fix, please make it concise.

Fix in Claude Code Fix in Cursor Fix in Codex

@kherembourg kherembourg changed the title feat: v6 cross-platform contract migration feat!: v6-only paywall API (remove v5) + single native module Jun 1, 2026
@kherembourg kherembourg force-pushed the feat/sdk-v6-migration branch from 2177306 to 40faea8 Compare June 16, 2026 09:25
kherembourg and others added 21 commits June 26, 2026 13:44
…ome, interceptor)

Introduces the TypeScript surface for the v6 bridge contract:

- `PresentationBuilder.placement(id) | screen(id) | default()` chain with
  `onLoaded`, `onPresented`, `onCloseRequested`, `onDismissed`.
- `PresentationRequest.preload()` and `display()` (resolves at dismiss).
- `PresentationOutcome` (5 fields: presentation, purchaseResult, plan,
  closeReason, error) with exclusion rule error ⇒ closeReason == null.
- `Transition`, `InterceptorInfo`, `InterceptResult`, `PresentationActionKind`,
  typed `ActionPayload` union.
- `PurchaselyBuilder` start chain (`apiKey().runningMode().allowDeeplink()…`)
  exposed via `Purchasely.builder(apiKey)`.
- `Purchasely.interceptAction`, `removeActionInterceptor`,
  `removeAllActionInterceptors`.

Legacy v5 APIs (`fetchPresentation`, `setPaywallActionInterceptor`,
`readyToOpenDeeplink`, `setPaywallActionInterceptorCallback`, `start({...})`)
are kept and annotated `@deprecated`.

Bumps the SDK version to 6.0.0 and updates the related test expectations.
All 139 existing tests still pass.

Ref: reports/v6-presentation-comparison-v3-claude/BRIDGE-CONTRACT.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…interceptor)

Adds a new `PurchaselyV6Bridge` helper that maps the v6 cross-platform contract
to the underlying Android v6 SDK:

- `v6Preload(requestId, payload)` / `v6Display(requestId, payload, transition)`
  build a `PLYPresentationBase.Prepared` from the JS payload, attach the
  `onPresented` / `onCloseRequested` / `onDismissed` callbacks and emit them
  through the existing `RCTDeviceEventEmitter` as
  `PURCHASELY_V6_{LOADED,PRESENTED,CLOSE_REQUESTED,DISMISSED}`.
- `v6Close(requestId)` / `v6Back(requestId)` provide programmatic control over
  the live presentation.
- `v6RegisterInterceptor(kind)` uses the new typed
  `Purchasely.interceptAction(actionType, callback)` (Java/`Class<>` overload)
  to expose every concrete `PLYPresentationAction` subclass and forwards the
  typed payload to JS through `PURCHASELY_V6_ACTION_INTERCEPTED`.
- `v6CompleteInterceptor(callbackId, result)` resolves the suspended
  `CompletableDeferred` with the JS-supplied `PLYInterceptResult`.
- `v6UnregisterInterceptor(kind)` calls `Purchasely.removeActionInterceptor`.
- `v6ApplyStartOptions({allowDeeplink, allowCampaigns})` chains the v6 start
  options onto the existing `start(...)` native method.

The legacy v5 bridge methods (`fetchPresentation`, `presentPresentation*`,
`setPaywallActionInterceptor`, `onProcessAction`) — whose underlying SDK APIs
are removed in v6 — now reject with a `v6_migration_required` message that
points consumers at the v6 builder. Internal `sendPurchaseResult` is rewritten
on top of `PLYPresentationOutcome` (`sendPurchaseResultV6`). `PLYProductActivity`
is reduced to a stub kept only for the AndroidManifest, and
`PurchaselyViewManager` is rewritten to preload + buildView with v6 APIs.

Bumps the native SDK dependencies (`core`, `google-play`, `huawei-services`,
`amazon`, `player`) to 6.0.0.

Known follow-ups:
- The Android SDK 6.0.0 must be published to Maven before the example app
  can build natively.
- `v6Back` is currently a no-op log; the SDK does not expose a per-request
  back API yet.

Ref: reports/v6-presentation-comparison-v3-claude/BRIDGE-CONTRACT.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
WIP scaffolding for the iOS v6 bridge (PurchaselyRNV6.h declares the
category on top of PurchaselyRN). Implementation comes next.

Also ignores local caches that polluted git status.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Implements the v6 cross-platform bridge contract on iOS using the existing
Purchasely 5.7.4 APIs while the native v6 SDK lands. Adds:

- v6Preload / v6Display / v6Close / v6Back exported methods
- v6RegisterInterceptor / v6UnregisterInterceptor / v6CompleteInterceptor
  using the single global setPaywallActionsInterceptor + a kind dispatcher
- v6ApplyStartOptions for allowDeeplink/allowCampaigns chain

Synthesizes the 5-field outcome (presentation, purchaseResult, plan,
closeReason, error) and onPresented(error?) callbacks per the contract
workarounds P0.2 / P0.4 / P1.1 — closeReason stays null on iOS until the
native pipeline exposes it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Exposes the 5 v6 lifecycle event names (PURCHASELY_V6_LOADED,
PRESENTED, CLOSE_REQUESTED, DISMISSED, ACTION_INTERCEPTED) through the
RCTEventEmitter supportedEvents array and pulls in the new V6 category
header so the methods are linked into the main module.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a v6 builder showcase to the example: PurchaselyBuilder.apiKey()
chained start, PresentationBuilder.placement() with onLoaded /
onPresented / onCloseRequested / onDismissed callbacks, and a typed
'purchase' interceptor.

The legacy v5 setupPurchasely() flow stays the default — the new
setupPurchaselyV6() entry is wired but commented out in useEffect so
users opt in explicitly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- README: add a "Migration to v6.x" section with before/after snippets
  for init, paywall display, action interceptor and the 5-field outcome
- CHANGELOG (new file): document the v6.0.0-beta.0 release contents,
  the dual API strategy, the iOS workarounds and the deprecated v5 entry
  points
- package.json: bump version to 6.0.0-beta.0

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
10 tests validating:
- PresentationBuilder.placement/screen → v6Preload payload format
- screenId → presentationId mapping (P1.1)
- display() resolves at DISMISS not at trigger (P0.3)
- onPresented synthesizes (null, error) on render fail (P0.4)
- Outcome carries 5 fields with closeReason / error mutually exclusive (P0.2)
- Action interceptor registry + cross-kind isolation
- Orphan events not auto-resolved (native handles timeout)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- gitignore: split the corrupted `.nx/workspace-datajest_dx/` line back into
  `.nx/workspace-data` + `jest_dx/` (merge dropped the trailing newline).
- android: stop double-firing onDismissed on display errors — reject only and
  let the JS .catch synthesize the dismissed outcome (matches iOS error path).
- ios: PresentationBuilder.default() now reads the `isDefault` flag and fetches
  the default presentation via fetchPresentationWith:nil (fixes 400 in preload
  + display).
- ios: serialise all access to the shared kV6* mutable collections behind
  @synchronized(kV6StateLock) to avoid RN-thread/main-queue data races.
- v6 close(): document + warn that the native SDK has no per-request close yet,
  so closeAllScreens() dismisses every displayed presentation.
Address the two open Greptile findings on the second review pass of
PurchaselyV6Module.kt:

- Interceptor timeout (P1, real): wrap `deferred.await()` in
  `withTimeoutOrNull(INTERCEPTOR_TIMEOUT_MS = 30s)` so the coroutine never
  suspends indefinitely when JS never calls `completeInterceptor` (e.g.
  after a bridge reload). On timeout we default to NOT_HANDLED and drop the
  `pendingInterceptors` entry, so neither the SDK action nor the `complete`
  lambda is held alive. This fulfils the "native must time out" contract
  already documented in v6.integration.test.ts.

- isDefault on Android (no behaviour change): an empty builder already
  resolves the default presentation — PLYPresentationManager routes a request
  with null placementId+presentationId to apiService.getPresentation(null),
  which substitutes "ply_default". This is the exact mirror of iOS
  fetchPresentationWith:nil; documented the intentional implicit handling in
  buildPrepared so it isn't re-flagged.

- Tests: lock `default()` -> `isDefault:true` with null placement/presentation
  ids (guards the iOS isDefault branch added in fbc99b6).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
BREAKING CHANGE: the legacy v5 paywall API is removed (not deprecated).
There is no soft-transition / dual mode anymore. Paywalls are displayed and
intercepted exclusively through the v6 builders. Version-agnostic core
methods (user, products, subscriptions, attributes, listeners,
presentSubscriptions, clientPresentation*) and the embedded PLYPresentationView
are UNCHANGED.

Removed (TS + iOS + Android):
- start({...})            → Purchasely.builder(apiKey)...start()
- fetchPresentation       → presentation.placement(id).build().preload()
- presentPresentation(*)  → presentation.placement|screen(id).build().display()
- presentProductWithIdentifier / presentPlanWithIdentifier
                          → presentation.screen(id).contentId(c).build().display()
- show/hide/closePresentation → request.display() / request.close()
- setPaywallActionInterceptor(Callback) / onProcessAction
                          → interceptAction(kind, handler)
- setDefaultPresentationResultCallback/Handler (TS + iOS)
                          → request.onDismissed(outcome => …)
- readyToOpenDeeplink (JS wrapper) → builder(apiKey).allowDeeplink(true).start()

Kept native primitives the v6 layer depends on: native start &
readyToOpenDeeplink (called by the v6 start builder on both platforms);
Android setDefaultPresentationResultHandler (the embedded view manager's
defaultPurchasePromise fallback). iOS removed its variant since the iOS view
uses purchaseResolve directly.

Details:
- TS (src/index.ts): dropped the 16 v5 paywall declarations + now-unused
  imports; v6 façade (builder/presentation/interceptAction) is the only
  paywall API. Pruned 19 obsolete tests in index.test.ts.
- iOS: removed 12 v5 paywall RCT methods + their exclusive private helpers
  and 4 header properties from PurchaselyRN.m/.h; v6 category & view intact;
  supportedEvents keeps the merged core+v6 event list.
- Android: removed the v5 paywall @ReactMethods + the orphaned ProductActivity
  inner class; deleted PLYProductActivity.kt, its manifest entry and proguard
  keep rule; transformPlanToMap & the v6 bridge intact.
- example/: rewritten to the v6 builder/presentation/interceptAction API.
- docs: added MIGRATION-v6.md (old→new mapping) and updated README,
  sdk_public_doc.md, CLAUDE.md and CHANGELOG.

Verified: yarn test (133 ✓), yarn typecheck ✓, yarn lint ✓. Native code is
not compilable in this environment (native 6.0.0 SDKs unpublished) and was
verified structurally (grep/brace-balance) + adversarial review.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ingle module

Removed the standalone `PurchaselyV6Bridge` object (PurchaselyV6Module.kt) and
inlined its logic directly into `PurchaselyModule` so the Android side exposes a
single native module.

- The 8 v6 @ReactMethod entry points (v6Preload/v6Display/v6Close/v6Back/
  v6RegisterInterceptor/v6UnregisterInterceptor/v6CompleteInterceptor/
  v6ApplyStartOptions) now hold the implementation directly instead of
  delegating to PurchaselyV6Bridge — the JS contract is unchanged.
- v6 helpers (buildV6Prepared, wireV6Callbacks, toV6Map/toV6Payload/toV6String/
  toV6Ordinal) are now private members; they reuse the module's existing
  `sendEvent` and companion `transformPlanToMap` (no duplication).
- Event-name constants, the interceptor timeout, and per-request state
  (activeV6Requests, pendingV6Interceptors) live in the companion object to
  preserve the process-global semantics the former object singleton had.
- Deleted packages/.../reactnativepurchasely/v6/PurchaselyV6Module.kt and the
  now-empty v6/ package directory. No remaining references to PurchaselyV6Bridge.

Verified: brace balance 276/276, no orphaned references, imports complete;
yarn test (133 ✓) / typecheck ✓ / lint ✓. Kotlin not compilable in this
environment (native 6.0.0 SDK unpublished) — verified structurally.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Mirror the Android single-module merge on iOS and remove the "v6" branding
from the whole codebase now that v6 is the only API.

iOS — single bridge:
- Merged the PurchaselyRN (V6) category into the main @implementation in
  PurchaselyRN.m (statics, C helpers, private methods and the 8 RCT bridge
  methods). Deleted PurchaselyRNV6.h / PurchaselyRNV6.m. One @implementation,
  braces 298/298.

TypeScript — no more v6 folder:
- Moved src/v6/{events,interceptor,presentation,startBuilder}.ts to src/ and
  src/v6/types.ts to src/presentationTypes.ts (avoids the src/types.ts clash).
  Folded the src/v6/index.ts barrel into src/index.ts. Renamed
  __tests__/v6.integration.test.ts -> presentation.integration.test.ts.

No "v6" mention left in code — synchronized rename across TS + iOS + Android +
example + tests (verified: zero [Vv]6 tokens in *.ts/tsx/kt/java/m/h/swift):
- Bridge methods: v6Preload->preloadPresentation, v6Display->displayPresentation,
  v6Close->closePresentation, v6Back->goBackToPreviousScreen,
  v6RegisterInterceptor->registerActionInterceptor,
  v6UnregisterInterceptor->unregisterActionInterceptor,
  v6CompleteInterceptor->completeActionInterceptor,
  v6ApplyStartOptions->applyStartOptions.
- Events: PURCHASELY_V6_LOADED/PRESENTED/CLOSE_REQUESTED/DISMISSED ->
  PURCHASELY_PRESENTATION_*, PURCHASELY_V6_ACTION_INTERCEPTED ->
  PURCHASELY_ACTION_INTERCEPTED, PURCHASELY_V6_EVENTS ->
  PURCHASELY_PRESENTATION_EVENTS, purchaselyV6EventEmitter ->
  presentationEventEmitter.
- All internal v6/V6-prefixed identifiers (iOS kV6*/V6*, Android *V6*, TS
  V6LifecycleEvent/V6InterceptorEvent) renamed; v6 log tags -> [Purchasely];
  NSError domain io.purchasely.v6 -> io.purchasely.presentation.

Wire names verified present & matching across TS/iOS/Android. The public API
(PresentationBuilder, PurchaselyBuilder, interceptAction, PLYPresentationView)
is unchanged. yarn test (133 ✓) / typecheck ✓ / lint ✓. Native verified
structurally (brace balance, single @implementation, wire-name sync) — the
6.0.0 SDK is unpublished so it cannot be compiled here.

Docs still reference "v6"/6.0.0 as the version/migration concept (out of scope
of the code-only rename).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ersion

- synchronize(): bridge the native success/error callbacks to a JS Promise
  (Android onSuccess/onError; iOS was already wired). Source-compatible — the
  promise resolves on completion and rejects on failure.
- Pin io.purchasely:* (core/google-play/player/amazon/huawei) and the Purchasely
  pod to 6.0.0-rc.1 (published on Maven Central / CocoaPods trunk).
- Fix the Android module test: import PLYPresentationType from its v6 package
  (io.purchasely.ext.presentation), unblocking the native unit-test suite.
- Bump all 5 packages + the bridge version to 6.0.0-rc.1.
- Docs: MIGRATION-v6 (synchronize awaitable), VERSIONS, sdk_public_doc,
  V6_MIGRATION_REPORT.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Migrates the repo from **React Native 0.79.2 → 0.86.0** (example app,
the 5 packages' devDeps, and `rn-purchasely-test`). Based on
`feat/sdk-v6-migration`. Supersedes #213 (RN 0.83).

- `react` 19.0.0 → **19.2.3**, `react-native` 0.79.2 → **0.86.0**
-
`@react-native/{babel-preset,eslint-config,metro-config,typescript-config,jest-preset}`
→ **0.86.0**
- `@react-native-community/cli` (+ platforms) 15.0.1 → **20.1.0**
- `@types/react` → **^19.2.0**, `react-test-renderer` → **19.2.3**,
`typescript` → **^5.8.3**, `metro-*` → **^0.84**

- Android: Gradle **8.12 → 9.3.1**, buildTools/compileSdk/targetSdk **35
→ 36** (minSdk 24, NDK 27.1.12297006, Kotlin 2.1.21 unchanged; New
Architecture already enabled).
- Node: `.nvmrc` **v20 → v22**, `engines.node >=22.11` (RN 0.86
requirement; CI `setup` reads `.nvmrc`).
- Root `resolutions`: `@types/react` → ^19.2.0, dropped obsolete
`@types/react-native`.

- Jest preset moved out of `react-native` → added
`@react-native/jest-preset`; `babel.config.js` →
`@react-native/babel-preset` (Hermes-Flow parser).
- `postinstall` restores the executable bit on
`@react-native-community/cli`'s `build/bin.js` (upstream 20.x packaging
bug that breaks Gradle autolinking under Yarn 3).
- `react-native-screens` → ^4.16 (`ShadowNode::Shared` removal in RN
0.86).
- Added `docs/react-native-upgrade-best-practices.md`.

- ✅ `yarn install`, `yarn typecheck`, `yarn lint`
- ✅ `yarn test` — 5 suites / 137 tests
- ✅ `yarn prepare` (builder-bob lib build)
- ✅ example Android `:app:assembleDebug` — BUILD SUCCESSFUL (Gradle
9.3.1, SDK 36, APK generated)

- iOS: `pod install` on a compatible Xcode (not run here; pbxproj
deployment target unchanged — 15.1 is the RN 0.86 minimum).
- `expo-purchasely-test` left on Expo SDK 54 (managed RN) — bump via
Expo SDK, not a raw RN pin.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…bscriptions

The iOS bridge still called native v5 APIs that were removed from Purchasely
6.0.0-rc.1, so the example app failed to compile (errors were masked behind the
earlier fmt failure). Migrate the bridge to the v6 builder API:

- PurchaselyRN.m / PurchaselyView.swift: PLYPresentationBuilder.forPlacementId/
  forScreenId -> build() -> preloadWithCompletion: + onDismissed:
  (PLYPresentationOutcome); keep showController:type:from: for display;
  closeDisplayedPresentation -> [presentation close] / closeAllScreens.
- RunningMode: TransactionOnly/PaywallObserver removed natively (only Observer=2/
  Full=3 remain) -> local PLYRNRunningMode ordinals + runningModeFromOrdinal()
  mapping, matching Android.
- setDynamicOffering: pass the new billingPlanType: argument (Unspecified).
- PLYPresentationPlan+Hybrid.m: self.default (ObjC keyword) -> self.default_.

BREAKING CHANGE: presentSubscriptions() is removed on iOS and Android (and from
the JS surface). The native v6 SDKs no longer ship a built-in subscription-list
UI; build your own screen from userSubscriptions().

Verified: yarn typecheck + lint clean, Jest 136/136, and
`react-native run-ios --device "iPhone KH"` builds, installs and launches on a
physical device.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- CHANGELOG: list presentSubscriptions() in the removed-methods section.
- MIGRATION-v6.md: presentSubscriptions() is removed on iOS and Android, not a
  source-compatible no-op.
- V6_MIGRATION_REPORT.md: add an addendum superseding doubts #2/#7 — the iOS
  bridge did not actually compile against rc.1; it is now migrated to the v6
  builder API and the example app builds/installs/launches on a physical device.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Align the iOS native fallback with the v6 default (observer) and with Flutter's
`PLYRunningMode(rawValue:) ?? .observer`. Previously an unknown/unset ordinal
resolved to Full (mirrored from Android); now only `full` opts into Purchasely
owning the purchase flow. `full`/`transactionOnly` still map to Full,
`observer`/`paywallObserver` to Observer.

Document the default-mode switch in MIGRATION-v6.md as a major, *silent*
behavioural breaking change (no compile error): apps that relied on v5's
implicit full mode must now pass `.runningMode('full')`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Completes the presentSubscriptions removal: the example Home screen still
wired a "Display Subscriptions" button to Purchasely.presentSubscriptions(),
which no longer exists and would throw at runtime when tapped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Align the React Native bridge with the Flutter SDK on the local
`6.0.0-beta.12` native build (resolved via mavenLocal), which ships the
renamed global handler `Purchasely.setDefaultPresentationDismissHandler`.
The previously pinned `6.0.0-rc.1` predates that rename.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@kherembourg kherembourg force-pushed the feat/sdk-v6-migration branch from a3c7954 to b7f4344 Compare June 26, 2026 11:46
@kherembourg

kherembourg commented Jun 26, 2026

Copy link
Copy Markdown
Contributor Author

📋 Changements d'API publique React Native — v5 → v6

Récapitulatif exhaustif de la surface publique impactée (diff main…HEAD, objet Purchasely + exports). Détail et exemples : MIGRATION-v6.md.

La surface retirée = l'API paywall v5, plus le tracking de présentation client (clientPresentationDisplayed/Closed) et le close() global. Tout le reste du cœur (user / products / subscriptions / attributes / listeners) est inchangé.

🟢 Ajouté — paywall & démarrage (builders v6)

Nouveau Rôle
Purchasely.builder(apiKey) Démarrage chaînable (.appUserId(), .runningMode(), .logLevel(), .allowDeeplink(), .allowCampaigns(), .stores() [Android], .storekitVersion() [iOS], .start())
Purchasely.presentation PLYPresentationBuilder : .placement(id), .screen(id), .contentId(id), .default().build()
PLYPresentationRequest (retour de build()) Cycle de vie : preload(), display(transition?), close(), back(), onDismissed(cb)
PLYPresentationOutcome (résolution de display()) Objet 5 champs { presentation, purchaseResult, plan, closeReason, error }, résolu au dismiss
Purchasely.interceptAction(kind, handler) Interception d'action typée (handler renvoie 'success' | 'failed' | 'notHandled')
Purchasely.removeActionInterceptor(kind) / removeAllActionInterceptors() Cleanup des interceptors
Purchasely.setDefaultPresentationDismissHandler(cb) / removeDefaultPresentationDismissHandler() Handler global pour les paywalls ouverts par le SDK (campagnes, deeplinks, Promoted IAP)

🟢 Ajouté — cœur (nouvelles méthodes / aliases v6)

Nouveau Rôle
allowCampaigns(bool) Active/désactive les campagnes automatiques (aussi disponible sur le builder)
allowDeeplink(bool) Autorisation des deeplinks à l'exécution (remplace readyToOpenDeeplink, aussi sur le builder)
listenToEvents(cb) / stopListeningToEvents() Aliases de addEventListener / removeEventListener (parité Flutter)
listenToPurchases(cb) / stopListeningToPurchases() Aliases de addPurchasedListener / removePurchasedListener
setUserAttributeListener(cb) / clearUserAttributeListener() Écoute unifiée des attributs (set/removed)
setUserAttributeWithInt / setUserAttributeWithDouble Aliases typés de setUserAttributeWithNumber
setUserAttributeWithIntArray / setUserAttributeWithDoubleArray Aliases typés de setUserAttributeWithNumberArray

🔴 Supprimé → remplacement

API v5 retirée Remplacement v6
start({ apiKey, androidStores, storeKit1, userId, logLevel, runningMode }) builder(apiKey).appUserId(userId).runningMode('full').logLevel('error').stores(['google']).storekitVersion('storeKit2').start()
fetchPresentation({ placementId }) presentation.placement(id).build().preload()
presentPresentationForPlacement({ placementVendorId }) presentation.placement(id).build().display()
presentPresentationWithIdentifier({ presentationVendorId }) presentation.screen(id).build().display()
presentPresentation({ presentation }) const req = presentation.placement(id).build(); await req.preload(); await req.display()
presentProductWithIdentifier(productId, …) presentation.screen(id).contentId(contentId).build().display()
presentPlanWithIdentifier(planId, …) presentation.screen(id).build().display()
showPresentation() request.display()
hidePresentation() / closePresentation() request.close()
close() (fermeture globale) request.close() (par requête)
setPaywallActionInterceptor(...) / setPaywallActionInterceptorCallback(cb) + onProcessAction(bool) interceptAction(kind, handler) (plus de onProcessAction)
setDefaultPresentationResultCallback(cb) / setDefaultPresentationResultHandler(cb) setDefaultPresentationDismissHandler(cb) (paywalls du SDK) / request.onDismissed(cb) (paywalls que vous affichez)
readyToOpenDeeplink(true) builder(apiKey).allowDeeplink(true).start() (ou allowDeeplink(true) à l'exécution)
clientPresentationDisplayed(...) / clientPresentationClosed(...) Supprimés — le tracking de présentation client v5 n'est plus exposé
presentSubscriptions() Supprimé — construisez l'écran depuis userSubscriptions() / userSubscriptionsHistory()

🟠 Changements de comportement / de type (à auditer)

  • ⚠️ runningMode par défaut = 'observer' (v5 ≈ full). Changement silencieux (pas d'erreur de compilation) : une app qui laissait Purchasely gérer l'achat cessera de le faire si elle ne passe pas explicitement .runningMode('full').
  • isDeeplinkHandled(...) renommé handleDeeplink(...) (alignement avec le SDK natif) — l'ancien nom est retiré.
  • purchaseResult (dans PLYPresentationOutcome) est désormais l'union de chaînes 'purchased' | 'cancelled' | 'restored' (PLYPurchaseResult), au lieu de l'enum ordinal ProductResult.
  • closeReason : 'button' | 'backSystem' | 'programmatic' (PLYCloseReason) — sur iOS, swipe-down / nav pop → backSystem pour parité Android.
  • synchronize() retourne maintenant Promise<boolean> (résout à la fin de la synchro, rejette en cas d'échec). Source-compatible : les appels fire-and-forget existants continuent de fonctionner.

✅ Inchangé (cœur du SDK)

userLogin, userLogout, getAnonymousUserId, isAnonymous · allProducts, productWithIdentifier, planWithIdentifier, purchaseWithPlanVendorId, signPromotionalOffer, isEligibleForIntroOffer, setDynamicOffering / getDynamicOfferings / removeDynamicOffering / clearDynamicOfferings · userSubscriptions, userSubscriptionsHistory, restoreAllProducts, silentRestoreAllProducts, userDidConsumeSubscriptionContent · setUserAttributeWithString / WithNumber / WithBoolean / WithDate / WithStringArray / WithNumberArray / WithBooleanArray, incrementUserAttribute, decrementUserAttribute, userAttributes, userAttribute, clearUserAttribute(s), clearBuiltInAttributes, setAttribute · listeners historiques (addEventListener / removeEventListener, addPurchasedListener / removePurchasedListener, addUserAttributeSetListener / removeUserAttributeSetListener, addUserAttributeRemovedListener / removeUserAttributeRemovedListener) · setLogLevel, setLanguage, setThemeMode, setDebugMode, revokeDataProcessingConsent, getConstants · composant embarqué PLYPresentationView.

Action kinds connus (pour interceptAction)

close, closeAll, login, navigate, purchase, restore, openPresentation, openPlacement, promoCode, webCheckout.

kherembourg and others added 10 commits June 26, 2026 14:38
T7: closeReason === 'programmatic' (was: any of the 3) and
    purchaseResult === 'cancelled' (was: unchecked) — locks the v6
    string-union contract on a programmatic close.
T9: closeReason === 'backSystem' (was: any of the 3) — Android BACK /
    iOS swipe-down parity.

Verified iOS locally: T7 closeReason=programmatic purchaseResult=cancelled,
T9 closeReason=backSystem; 13/13 pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Mirror the Android bridge's INTERCEPTOR_TIMEOUT_MS (30_000L): if JS never calls completeActionInterceptor: (RN reload, listener torn down, handler threw before completing), fire the stored callback with notHandled after 30s so the SDK's completion block always runs and the action is never frozen for the process lifetime. Whoever removes the kInterceptorCallbacks entry first (this timer or completeActionInterceptor:) wins; the loser reads nil and no-ops, so the SDK completion can never fire twice.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Delete packages/{amazon,android-player,google,huawei}/turbo.json
- Drop 'turbo' devDependency from the same four packages
- Drop dead 'e2eBuild' guard in example/android/app/build.gradle
  (property is never set; release APK is built unconditionally via :app:assembleRelease)
- Reconcile yarn.lock (turbo binaries removed)

PR #211 already removed the 'yarn turbo run' invocations from
ci.yml, but the per-package turbo.json + devDep were left behind as
dead code. This finishes the cleanup.

Verified locally:
  yarn install:               OK (turbo binaries removed)
  yarn test:                  5 suites / 140 tests passed
  yarn typecheck:             OK
  yarn workspace react-native-purchasely lint: OK
  yarn workspace react-native-purchasely prepare: OK
- Restore PLY prefix on all v6 public types to match Flutter & native SDKs
  (PresentationBuilder→PLYPresentationBuilder, PresentationRequest→PLYPresentationRequest,
   Presentation→PLYPresentation, Transition→PLYTransition, CloseReason→PLYCloseReason,
   PurchaseResultKind→PLYPurchaseResult, InterceptResult→PLYInterceptResult,
   PresentationActionKind→PLYPresentationActionKind, ActionPayload→PLYActionPayload,
   InterceptorHandler→PLYActionInterceptorHandler, InterceptorInfo→PLYInterceptorInfo,
   *Payload→PLY*Payload, PresentationError→PLYPresentationError, internal *Event types)
- Add PurchaselyBuilder.handleDeeplink(url) for cold-start deeplink replay
  (parity with Flutter), replayed after start() via existing bridge method
- Add campaignId/flowId to PLYPresentation interface + normalizePresentation
- Implement back() on iOS (mirror close(): lookup by requestId, respondsToSelector guard)
- Sync bridge/sdk version to 6.0.0-rc.2 (was rc.1/stale)
- Fix close() JSDoc: iOS does per-request close, Android dismisses all
- Remove internal V6_MIGRATION_REPORT.md (PR references MIGRATION-v6.md)
- Drop stale turbo.json references from CLAUDE.md

Verified: yarn typecheck, yarn test (140/140), yarn lint all green.
Public API parity (Flutter v6)
- Add Purchasely.apiKey(...) alias of Purchasely.builder(...)
- Add Purchasely.allowDeeplink / allowCampaigns (runtime + builder chain)
- Add listener aliases: listenToEvents / stopListeningToEvents,
  listenToPurchases / stopListeningToPurchases
- Add setUserAttributeListener / clearUserAttributeListener
- Add setUserAttributeWithInt / setUserAttributeWithDouble and
  setUserAttributeWithIntArray / setUserAttributeWithDoubleArray
- Drop top-level Purchasely.close(),
  Purchasely.displaySubscriptionCancellationInstruction(),
  Purchasely.clientPresentationDisplayed / clientPresentationClosed
- Drop obsolete types: FetchPresentationParameters,
  PresentPresentationParameters, PresentPresentationWithIdentifierParameters,
  PresentPresentationPlacementParameters, PresentProductParameters,
  PresentPlanParameters, PaywallActionInterceptorResult, PLYPaywallInfo,
  PresentPresentationResult, FetchPresentationResult
- Interceptor normalizeInfo now uses the full PLYPresentation contract
  (audience, AB-test, campaign, flow, language, plans, metadata, height)

Native bridges
- Android PurchaselyModule: remove @ReactMethod close, the
  displaySubscriptionCancellationInstruction handler, the
  clientPresentationDisplayed / clientPresentationClosed handlers and
  the FragmentActivity / presentationsLoaded plumbing
- Android PurchaselyModule.toRNMap now forwards campaignId and flowId
- iOS PurchaselyRN: add allowDeeplink: and allowCampaigns:, keep
  readyToOpenDeeplink: as a native alias of allowDeeplink:, apply both
  start options through applyStartOptions:, drop
  findPresentationLoadedFor / clientPresentationDisplayed /
  clientPresentationClosed
- iOS PurchaselyRN presentationToMap now forwards campaignId and flowId

E2E + CI parity (Flutter v6)
- Port Flutter T14-T20 to example/src/E2ETestRunner.tsx
  (extended attribute types, bulk attributes, inc/dec, catalog lookup,
  dynamic offerings CRUD, screen(id) + modal/popin transitions, config
  setter smoke test)
- run_e2e.sh / run_e2e_ios.sh: T1-T20 + 10 min timeout
- e2e-android.yml / e2e-ios.yml: T1-T20 labels, pull_request trigger
  with path filters, E2E suite now runs on PR
- ci.yml: add :react-native-purchasely:testDebugUnitTest after the
  Android example build so every PR validates the Kotlin bridge
- integration_test/E2E_TEST_INDEX.md: T14-T20 documented

Build infra / docs
- Restore package.json versions (main + google/amazon/huawei/android-player)
- MIGRATION-v6.md: v6 helpers (allowCampaigns, listenToEvents, …) and
  removed APIs
- CHANGELOG: 6.0.0-rc.2 entry covering the new helpers, the removals,
  the native map additions and the E2E/CI changes
- PurchaselyModule already pinned to 6.0.0-rc.2 native SDKs; iOS pod
  spec unchanged
…orrect overload

The v6 Purchasely.setUserAttribute signatures are suspend functions with overloads
for String[] / Float[] / Boolean[]. When the RN bridge passes an untyped
.toTypedArray() result, the suspend bridge throws 'Cannot convert argument of
type class [Ljava.lang.String;' on the first array call. Typing the locals
explicitly (Array<String>, Array<Float>, Array<Boolean>) makes the Kotlin
compiler pick the matching overload instead of failing the JS->native conversion.
The if/else that picked int vs float confused v6's suspend bridge into
resolving the String[] overload, throwing 'Cannot convert argument of
type class [Ljava.lang.String;' on the first call. Always pass a Float
explicitly so the compiler picks the correct overload at compile time
(Kotlin won't implicitly convert Double→Float across a suspend call).
…ismissHandler

The v5 paywall verbs (fetchPresentation, presentPresentation*,
presentProductWithIdentifier, presentPlanWithIdentifier, hide/showPresentation,
setPaywallActionInterceptor, onProcessAction) were removed from the public API
and the native bridges in v6, leaving only dead jest stubs. Drop them from the
central mock and the inline mocks.

Add the missing removeDefaultPresentationDismissHandler stub for parity with the
public API (index.ts/presentation.ts) and with Flutter.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reading an array-typed attribute (String[]/Integer[]/Float[]/Boolean[]) crashed
E2E T14 with "Exception in HostFunction: Cannot convert argument of type class
[Ljava.lang.String;". Root cause is the read path, not the write:
Purchasely.userAttribute() returns a raw Java array, and getUserAttributeValueForRN
returned it untouched (else -> value). Under the New Architecture, promise.resolve()
hands that raw array to Arguments (Arguments.kt), which rejects it.

Normalize Array<*>/List<*> through Arguments.makeNativeArray, converting each
element recursively (Int/Float -> Double) so it matches the scalar path and iOS.

Adds a red/green regression guard in PurchaselyModuleTest (mockStatic on
Purchasely + Arguments): the raw array must never reach promise.resolve().

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The previous comment described a "suspend overload resolved by reflection"
theory that turned out to be wrong: the SDK overloads return Deferred (not
suspend) and are resolved at compile time. The real reason for value.toFloat()
is that RN passes numbers as Double and the SDK has no Double overload.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@kherembourg kherembourg requested a review from Copilot July 1, 2026 13:07
@kherembourg

Copy link
Copy Markdown
Contributor Author

@greptileai review

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR migrates the Purchasely React Native SDK to a v6-only paywall surface (builder + presentation request lifecycle + typed interceptors), bumps native dependencies to 6.0.0-rc.2, upgrades example/test tooling to React Native 0.86.0, and adds Android/iOS E2E test workflows plus host-side drivers.

Changes:

  • Replace v5 paywall APIs with the v6 builder/presentation/interceptor façade (TypeScript + native bridge alignment).
  • Upgrade SDK/package versions (JS, iOS pod, Android artifacts) and modernize example + test-project tooling for RN 0.86 / Node 22.
  • Add E2E orchestration scripts and CI workflows for Android + iOS device-driven suites.

Reviewed changes

Copilot reviewed 80 out of 83 changed files in this pull request and generated 8 comments.

Show a summary per file
File Description
VERSIONS.md Native SDK version mapping table update (currently inconsistent with rc.2).
test-projects/rn-purchasely-test/package.json Test project dependency + tooling bumps (RN 0.86 / TS 5.8 / Node 22 engines).
test-projects/rn-purchasely-test/jest.config.js Add RN Jest preset config for the test project.
scripts/fix-rnc-cli-permissions.js Postinstall workaround to chmod RN CLI bin for Yarn node-modules linker.
README.md Public README updated to v6 builder/presentation API examples.
packages/purchasely/src/types.ts Remove v5 paywall-related public types; extend presentation plan fields.
packages/purchasely/src/startBuilder.ts New v6 start/builder façade (Purchasely.builder(...).start()).
packages/purchasely/src/presentationTypes.ts New v6 presentation/outcome/interceptor TS contract types.
packages/purchasely/src/presentation.ts New v6 presentation builder/request lifecycle + default dismiss handler wiring.
packages/purchasely/src/interfaces.ts Remove v5 presentation parameter interfaces.
packages/purchasely/src/interceptor.ts New typed action interceptor registry + native completion bridging.
packages/purchasely/src/index.ts Replace v5 API surface with v6-only façade + aliases and exports.
packages/purchasely/src/events.ts New NativeEventEmitter event names and payload typings for v6 lifecycle.
packages/purchasely/src/components/PLYPresentationView.tsx Relax embedded view callback typing (now any) to avoid removed v5 types.
packages/purchasely/src/tests/types.test.ts Remove tests for removed v5 types.
packages/purchasely/src/tests/presentation.integration.test.ts Add integration tests covering v6 presentation + interceptors + outcomes.
packages/purchasely/src/tests/index.test.ts Update SDK tests for v6 builder surface + new aliases.
packages/purchasely/src/mocks/testUtils.ts Update mocks to match new native module surface.
packages/purchasely/README.md Package README rewritten for v6-only initialization + paywall/interceptor usage.
packages/purchasely/react-native-purchasely.podspec Pin iOS native pod dependency to 6.0.0-rc.2.
packages/purchasely/package.json Bump package version to 6.0.0-rc.2 + RN 0.86 dev deps + Jest preset.
packages/purchasely/ios/PurchaselyView.swift Update embedded iOS view to v6 preload/controller install pipeline.
packages/purchasely/ios/PurchaselyTests/PurchaselyRNTests.m Add tests around default dismiss event + exported method.
packages/purchasely/ios/PurchaselyRN.h Update presentationsLoaded typing for v6 protocol-based presentation objects.
packages/purchasely/ios/Classes/Hybrid/PLYPresentationPlan+Hybrid.m Add v6 plan fields (offerVendorId, default).
packages/purchasely/CHANGELOG.md New changelog documenting v6-only surface and breaking removals.
packages/purchasely/babel.config.js Switch preset to @react-native/babel-preset.
packages/purchasely/android/src/test/java/com/reactnativepurchasely/PurchaselyModuleTest.kt Add regression unit test for array attribute conversion via Arguments.makeNativeArray.
packages/purchasely/android/src/main/java/com/reactnativepurchasely/PurchaselyViewManager.kt Refactor embedded Android view manager to v6 preload/outcome model.
packages/purchasely/android/src/main/java/com/reactnativepurchasely/PLYSubscriptionsActivity.kt Delete legacy subscriptions activity (v6 removed surface).
packages/purchasely/android/src/main/java/com/reactnativepurchasely/PLYProductActivity.kt Delete legacy product activity (v6 removed surface).
packages/purchasely/android/src/main/AndroidManifest.xml Remove legacy activity registrations.
packages/purchasely/android/consumer-rules.pro Update proguard rules (currently still references deleted activity).
packages/purchasely/android/build.gradle Pin Android core native dependency to 6.0.0-rc.2.
packages/huawei/turbo.json Remove Turbo task config.
packages/huawei/package.json Bump package to 6.0.0-rc.2; remove Turbo; align RN 0.86 tooling.
packages/huawei/babel.config.js Switch preset to @react-native/babel-preset.
packages/huawei/android/build.gradle Pin Huawei native dependency to 6.0.0-rc.2; add repositories (incl. mavenLocal).
packages/google/turbo.json Remove Turbo task config.
packages/google/package.json Bump package to 6.0.0-rc.2; remove Turbo; align RN 0.86 tooling.
packages/google/babel.config.js Switch preset to @react-native/babel-preset.
packages/google/android/build.gradle Pin Google native dependency to 6.0.0-rc.2; add repositories (incl. mavenLocal).
packages/android-player/turbo.json Remove Turbo task config.
packages/android-player/package.json Bump package to 6.0.0-rc.2; remove Turbo; align RN 0.86 tooling.
packages/android-player/babel.config.js Switch preset to @react-native/babel-preset.
packages/android-player/android/build.gradle Pin player native dependency to 6.0.0-rc.2; add repositories (incl. mavenLocal).
packages/amazon/turbo.json Remove Turbo task config.
packages/amazon/package.json Bump package to 6.0.0-rc.2; remove Turbo; align RN 0.86 tooling.
packages/amazon/babel.config.js Switch preset to @react-native/babel-preset.
packages/amazon/android/build.gradle Pin Amazon native dependency to 6.0.0-rc.2; add repositories (incl. mavenLocal).
package.json Add Node engines >=22.11 and postinstall permission fix; update TS/react typings resolutions.
MIGRATION-v6.md New comprehensive v5 → v6 migration guide.
integration_test/tools/tap_purchase.sh Android host UI driver script for purchase tapping.
integration_test/tools/tap_purchase_ios.sh iOS simulator host UI driver script for purchase tapping (idb).
integration_test/tools/swipe_dismiss_ios.sh iOS simulator host UI driver for dismissing paywalls (tap/swipe).
integration_test/tools/press_back.sh Android host UI driver script for back-dismiss.
integration_test/run_e2e.sh Android E2E orchestrator script (T1–T20).
integration_test/run_e2e_ios.sh iOS simulator E2E orchestrator script (T1–T20).
example/src/Paywall.tsx Example embedded paywall screen updated to placement-driven view.
example/src/Home.tsx Example home updated to v6 request lifecycle (preload/display/close/back).
example/src/App.tsx Example app updated to v6 builder + typed interceptors + preload demo.
example/package.json Example app dependency/tooling bump to RN 0.86 and Node 22 engines.
example/jest.config.js Switch example Jest preset to @react-native/jest-preset.
example/ios/Podfile Add build workaround defines for fmt consteval issues on newer clang/Xcode.
example/ios/example/AppDelegate.swift Route to E2E runner via initial props when launched with E2E flag.
example/index.js Root component routing to E2E runner when e2eMode initial prop is set.
example/android/gradle/wrapper/gradle-wrapper.properties Upgrade Gradle wrapper to 9.3.1.
example/android/build.gradle Bump Android SDK versions to 36 tooling/compile/target.
example/android/app/src/main/java/com/purchasely/MainActivity.kt Add E2E launch options + pause override to avoid Hermes timer freeze.
CLAUDE.md Contributor/AI context updated (currently inconsistent with PR versions).
.nvmrc Update Node version to v22.
.gitignore Add caches/coverage ignore entries.
.github/workflows/e2e-ios.yml New iOS E2E CI workflow.
.github/workflows/e2e-android.yml New Android E2E CI workflow (currently pins Node 20).
.github/workflows/ci.yml CI updated to run Android native unit tests after Android build.
Comments suppressed due to low confidence (1)

packages/huawei/android/build.gradle:60

  • mavenLocal() being first in repositories can cause non-reproducible dependency resolution (local artifacts overriding the published ones). For a published integration package, it’s usually safer to rely on google()/mavenCentral()/Huawei repo only.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 3 to 6
-keep class com.reactnativepurchasely.PurchaselyModule { *; }
-keep class com.reactnativepurchasely.PurchaselyPackage { *; }
-keep class com.reactnativepurchasely.PLYProductActivity { *; }
-keep class com.reactnativepurchasely.PLYSubscriptionsActivity { *; }

Comment thread VERSIONS.md
Comment on lines 115 to 117
| 5.7.2 | 5.7.2 | 5.7.3 |
| 6.0.0-rc.1 | 6.0.0-rc.1 | 6.0.0-rc.1 |
| 5.7.3 | 5.7.4 | 5.7.4 |
Comment thread CLAUDE.md
Comment on lines 12 to 18
| Property | Value |
|----------|-------|
| Current Version | 5.7.3 |
| Current Version | 6.0.0-rc.1 |
| React Native | 0.79.2 |
| TypeScript | 5.2.2 (strict mode) |
| Node.js | v20 (see `.nvmrc`) |
| Package Manager | Yarn 3.6.1 (workspaces) |
Comment thread CLAUDE.md Outdated
Comment on lines +49 to +53
- name: Setup Node.js 20
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'yarn'
Comment on lines 53 to 57
repositories {
mavenLocal()
google()
mavenCentral()
}
Comment on lines 52 to 56
repositories {
mavenLocal()
google()
mavenCentral()
}
Comment on lines 53 to 57
repositories {
mavenLocal()
google()
mavenCentral()
}
kherembourg and others added 13 commits July 2, 2026 13:34
io.purchasely:*:6.0.0-rc.2 requires minSdk 23; keeping 21 in the RN
packages breaks consumer manifest merging.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
….2 surface

- versions: 6.0.0-rc.2 everywhere (npm, native iOS/Android pins), RN 0.86,
  TS 5.8.3, Node v22, minSdk 23; VERSIONS.md gains the rc.2 row and the
  5.7.3 line is back in chronological order
- MIGRATION-v6.md: real exported type names (PLYPresentationBuilder,
  PLYPresentationRequest), defaultSource() entry point, presentation
  builder options (backgroundColor/progressColor/displayCloseButton/
  displayBackButton with per-platform semantics), full transition types
  and dimensions, cold-start handleDeeplink builder modifier, and the
  PLYLoadedPresentation lifecycle (display/close/back)
- drop the last unprefixed/legacy enum references (PLYPaywallAction,
  4-value RunningMode) from CLAUDE.md

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…presentation builder

The JS presentation builder has been sending these keys since v6, and the
Android bridge already consumes them (PurchaselyModule.kt); the iOS bridge
silently dropped them. Tri-state guard mirrors Android: absent key or JSON
null leaves the backend-defined visibility untouched. Note iOS semantics
are suppression-only (only false hides a backend-shown button).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- PLYPresentationBuilder.defaultSource() is the canonical cross-platform
  entry point (default() stays as the iOS-matching alias)
- preload() now resolves a PLYLoadedPresentation exposing
  display(transition?)/close()/back() delegating to its origin request,
  mirroring the Flutter loaded-presentation lifecycle
- drop the legacy v5 PLYPaywallAction enum (snake_case, open_flow_step);
  PLYPresentationActionKind is the only action vocabulary
- reduce RunningMode to OBSERVER/FULL — the v6 natives removed
  TransactionOnly and PaywallObserver
- expose a public read-only requestId getter on PLYPresentationRequest
- document the real displayCloseButton/displayBackButton platform
  semantics (Android toggle, iOS suppression-only)

BREAKING CHANGE: PLYPaywallAction is removed and RunningMode no longer
declares TRANSACTION_ONLY/PAYWALL_OBSERVER.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…t={...}>

Port of PR #254 onto the aligned v6 API. The component accepts a
PLYPresentationRequest preloaded upstream and reuses its native
presentation by requestId on both platforms (iOS
loadedPresentationForRequestId, Android PurchaselyModule.loadedPresentation),
falling back to placementId/presentation. onPresentationClosed is now
typed with the exported PLYPresentationViewResult ({ result, plan }, which
is what the native embedded view actually emits). Example gains a
PaywallPreloaded demo screen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…handler

Align the dismiss routing rule with Flutter/native (local onDismissed ??
global default handler): a presentation displayed by the app whose request
has no local onDismissed now delivers its outcome to the handler registered
via setDefaultPresentationDismissHandler, in addition to resolving the
display() promise. A local onDismissed still wins and suppresses the
default routing for that presentation.

The native DEFAULT_DISMISSED event only fires for SDK-opened presentations
(campaign/deeplink/Promoted IAP) which carry no requestId, so each
dismissal reaches the default handler through exactly one path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add the seven scenarios present in the Flutter E2E suite but missing here:
- T21 synchronize() settles (resolve or clean store rejection, no hang)
- T22 default dismiss handler catches a fire-and-forget display()
- T23 local onDismissed wins over the default handler
- T24 user attribute listener set/removed events
- T25 embedded <PLYPresentationView request> renders (close best-effort,
  per Flutter INLINE_PAYWALL_CLOSE.md findings)
- T26 PLYLoadedPresentation display/close lifecycle
- T27 cold-start deeplink via builder .handleDeeplink() before start():
  Android runs it as a dedicated fresh-process phase (E2E_PHASE intent
  extra relaunch in run_e2e.sh); iOS emits an explicit [E2E:T27:SKIP]
  (no launch-arg phase bridge in the example AppDelegate yet)

Adds a skip status and [E2E:Tn:SKIP] marker, extends both host scripts'
report loops to T21-T27, and documents everything (steps, asserts,
divergences vs Flutter, cross-wrapper mapping) in E2E_TEST_INDEX.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The path filters only watched integration_test/, the example runner and
build.gradle/podspec files, so a pure bridge change (packages/purchasely
JS, iOS or Android source) never triggered the E2E workflows. Add
packages/purchasely/src/**, packages/purchasely/ios/** and
packages/purchasely/android/src/** to push and pull_request filters of
both workflows, matching how Flutter gates its E2E on purchasely/lib/**.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Mounting <PLYPresentationView request={...}> hard-froze the whole app
(CI E2E T25): Fabric instantiated the PurchaselyView manager on the main
thread while the JS effect concurrently resolved the same module via
NativeModules.PurchaselyView; with requiresMainQueueSetup=true the JS
thread dispatch_sync'd onto the blocked main queue — a module-holder
deadlock proven by process samples (main thread in
condition_variable::wait, JS thread in RCTUnsafeExecuteOnMainQueueSync).
requiresMainQueueSetup now returns false; view() is still main-queue.

Also rework the controller mounting to the pattern proven in the Flutter
NativeView: frame-based layout resynced in layoutSubviews (no Auto Layout
against the Yoga host), addChild/didMove containment on the root VC, and
a BALANCED appearance transition driven from didMoveToWindow (the old
begin-without-end left viewDidAppear unfired), with symmetric teardown.

The iOS SDK only emits PRESENTATION_VIEWED through its own full-screen
display flow, so a manually embedded controller rendered without ever
surfacing the event (Android's SDK does fire it for embedded views). The
view now emits it to JS listeners when the paywall actually appears —
cross-platform parity, no backend analytics involved, and no double-fire
possible since the full-screen path never goes through PurchaselyView.

Verified locally: full iOS E2E suite green (T1-T26 PASS, T27 SKIP).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Podfile.lock finally records the local pod at 6.0.0-rc.2 (podspec was
bumped earlier without regenerating the lock); the pbxproj/Info.plist
changes (RCT_REMOVE_LEGACY_ARCH, PODFILE_DIR, RCTNewArchEnabled) are
injected automatically by the React Native CocoaPods scripts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants