feat: v6 cross-platform contract migration#243
Conversation
…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>
|
| Filename | Overview |
|---|---|
| packages/purchasely/android/src/main/java/com/reactnativepurchasely/v6/PurchaselyV6Module.kt | New v6 Android bridge: preload/display/close/interceptor wiring with ConcurrentHashMap state, 30s interceptor timeout, and color-parsing guards. Solid implementation with prior review items addressed. |
| packages/purchasely/android/src/main/java/com/reactnativepurchasely/PurchaselyModule.kt | v5 presentation methods (fetchPresentation, presentPresentation, etc.) now hard-reject with 'v6_migration_required' — contradicting the PR's stated 'no removal yet' policy and silently breaking existing Android v5 consumers. |
| packages/purchasely/ios/PurchaselyRNV6.m | New iOS v6 bridge (665 lines): presentation lifecycle, interceptor, and start options implemented as a category on PurchaselyRN. Missing timeout on interceptor callbacks (unlike the Android 30s guard), which can permanently block native SDK actions. |
| packages/purchasely/src/v6/presentation.ts | PresentationBuilder/PresentationRequest JS facade: clean lifecycle event binding, subscription cleanup, and display() Promise semantics. No issues found. |
| packages/purchasely/src/v6/interceptor.ts | Action interceptor JS layer: per-kind registry, native registration/unregistration, and async handler dispatch. Dead branch in normalizePayload (P2 style). |
| packages/purchasely/src/v6/types.ts | Well-typed v6 contract types — Presentation, PresentationOutcome, ActionPayload union, InterceptorHandler. No issues found. |
| packages/purchasely/src/v6/startBuilder.ts | PurchaselyBuilder chain for v6 start: maps string enums to legacy ordinals and delegates to existing native start() + new v6ApplyStartOptions(). Clean implementation. |
| packages/purchasely/src/tests/v6.integration.test.ts | 10 new integration tests covering preload, display, interceptor lifecycle, and timeout. Good event-driven mock harness. |
Sequence Diagram
sequenceDiagram
participant JS as JS (React Native)
participant Bridge as Native Bridge (Android/iOS)
participant SDK as Purchasely SDK
Note over JS,SDK: display() flow
JS->>Bridge: v6Display(requestId, payload, transition)
Bridge-->>JS: Promise.resolve(true)
Bridge->>SDK: prepared.display() / fetchPresentationFor:
SDK-->>Bridge: onPresented(presentation)
Bridge-->>JS: PURCHASELY_V6_PRESENTED event
JS->>JS: onPresented callback
SDK-->>Bridge: onDismissed(outcome)
Bridge-->>JS: PURCHASELY_V6_DISMISSED event
JS->>JS: resolve(PresentationOutcome)
Note over JS,SDK: interceptAction() flow
JS->>Bridge: v6RegisterInterceptor(kind)
Bridge->>SDK: "setPaywallActionsInterceptor / interceptAction<T>"
SDK-->>Bridge: action triggered
Bridge-->>JS: PURCHASELY_V6_ACTION_INTERCEPTED (callbackId)
JS->>JS: "handler(info, payload) -> result"
JS->>Bridge: v6CompleteInterceptor(callbackId, result)
Bridge->>SDK: complete(PLYInterceptResult)
Prompt To Fix All With AI
Fix the following 3 code review issues. Work through them one at a time, proposing concise fixes.
---
### Issue 1 of 3
packages/purchasely/android/src/main/java/com/reactnativepurchasely/PurchaselyModule.kt:293-320
**v5 presentation methods hard-removed on Android despite "no removal yet" claim**
`fetchPresentation`, `presentPresentation`, `presentPresentationWithIdentifier`, `presentPresentationForPlacement`, `presentProductWithIdentifier`, `presentPlanWithIdentifier`, `setPaywallActionInterceptor`, and `onProcessAction` all now immediately reject with `"v6_migration_required"` on Android. The PR description explicitly states _"v5 APIs remain (`@deprecated`). Once the iOS native fixes land we'll cut a 6.0.0 GA and remove v5."_, but the Android native implementation has already removed these methods. Any app calling the `@deprecated` JS wrappers (which still delegate to `NativeModules.Purchasely.fetchPresentation(...)`) will receive a runtime rejection on Android, silently breaking existing integrations that haven't migrated yet.
### Issue 2 of 3
packages/purchasely/ios/PurchaselyRNV6.m:506-514
**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`.
### Issue 3 of 3
packages/purchasely/src/v6/interceptor.ts:49-60
Dead branch in the early-return guard — both the inner `if` and the fall-through return `null`, making the kind-check unreachable.
```suggestion
if (!raw) {
return null;
}
```
Reviews (3): Last reviewed commit: "fix(v6): bound Android interceptor wait;..." | Re-trigger Greptile
- 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.
|
All 5 Greptile findings addressed in
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 Generated by Claude Code |
|
@greptileai review Generated by Claude Code |
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>
|
@greptileai review |
| 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); | ||
| }; |
There was a problem hiding this 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.
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.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>
Summary
Migrates the React Native SDK to the v6 cross-platform contract documented in
reports/v6-presentation-comparison-v3-claude/BRIDGE-CONTRACT.md.PresentationBuilder,PresentationRequest,Presentation,PresentationOutcome(5 fields), typedinterceptAction,PurchaselyBuilderstart chain.PLYPresentation { … }DSL, sealed-classinterceptAction<T>, enrichedPLYPresentationOutcome).PurchaselyRNV6.h/.m) implements the contract on top of the existing legacyPurchaselyRNmodule. Workarounds in place until native iOS catches up (cf. TODOs).setupPurchaselyV6()demo.Breaking changes (next major)
The v6 façade is opt-in via the new exports. v5 APIs remain (
@deprecated). Once the iOS native fixes land we'll cut a 6.0.0 GA and remove v5.iOS TODOs (tracked in
PurchaselyRNV6.mcomments + CHANGELOG)closeReasononce native iOS exposes the dismissal reason (currently alwaysnull).screenIddirectly when native iOS adds the property (currently aliased topresentation.id).onPresented(null, error)once native callback ships (P0.4).webCheckoutProviderenum mapping against the v6 podspec when it lands.back()is a no-op + warning log (legacy iOS SDK has noback()primitive).Test plan
npx tsc --noEmit— 0 errorsyarn lint— 0 errors (5 warnings in auto-generatedcoverage/lcov-report/)yarn test— 149/149 pass (139 existing + 10 new v6 integration)cd example/ios && pod install— 76 pods OKcd example/android && ./gradlew assembleDebug— to be run on CI (local sandbox blocked it)Reference
reports/v6-presentation-comparison-v3-claude/BRIDGE-CONTRACT.mdreports/v6-presentation-comparison-v3-claude/index.html🤖 Generated with Claude Code