Skip to content

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

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

feat!: v6-only paywall API (remove v5) + single native module#243
kherembourg wants to merge 30 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 and unifies the native layer into one module per platform.

What this PR now does (updated — the branch evolved past the original "façade alongside v5" approach):

  • v5 paywall API removed (not deprecated). Paywalls are displayed and intercepted exclusively through the builders: PurchaselyBuilder (start), PresentationBuilderPresentationRequest (preload / display / close / back) with a 5‑field PresentationOutcome, and typed interceptAction. There is no dual/soft‑transition mode.
  • Core SDK is unchanged and version‑agnostic: user management, products, subscriptions, attributes, restore, event listeners, presentSubscriptions, client‑presentation tracking, and the embedded PLYPresentationView all keep their existing API.
  • Single native module per platform:
    • iOS — the v6 bridge category was merged into PurchaselyRN.m; PurchaselyRNV6.h/.m are deleted. One Objective‑C bridge.
    • AndroidPurchaselyV6Bridge was merged into PurchaselyModule.kt; the v6/ package is gone. One Kotlin module.
  • No "v6" naming left in the code. Now that v6 is the only API, the v6 branding was removed everywhere (TypeScript src/v6/ folder dissolved into src/, bridge method/event names renamed, internal identifiers and log tags cleaned). v6 survives only as the SDK version (6.0.0-beta.0) and in the migration guide. The public API names are unchanged.
  • Example app rewritten to the builder/PresentationBuilder/interceptAction API.
  • Migration guide added: MIGRATION-v6.md maps every removed v5 paywall method to its replacement (the Purchasely AI plugin/skills can assist the migration).

Breaking changes

The v5 paywall methods are removed: start({...}), fetchPresentation, presentPresentation*, presentProductWithIdentifier, presentPlanWithIdentifier, show/hide/closePresentation, setPaywallActionInterceptor(Callback), onProcessAction, setDefaultPresentationResultCallback/Handler, readyToOpenDeeplink. See MIGRATION-v6.md for the old → new mapping. Core (user/products/subscriptions/attributes/listeners) is not affected.

Architecture

  • One RN module named Purchasely per platform (PurchaselyRN / PurchaselyModule); the paywall bridge logic lives directly inside it.
  • The JS↔native wire (method + event names) is synchronized across TypeScript, iOS and Android and verified by the façade integration tests.

iOS limitations (until the native iOS 6.0.0 SDK ships)

  1. closeReason is not yet surfaced by native iOS (absent/button).
  2. back() (goBackToPreviousScreen) is a no‑op + warning log — the legacy iOS SDK exposes no back() primitive.
  3. webCheckoutProvider enum mapping is written against the 6.0.0 pod and will only compile once that pod is published (see CI note below).

Test plan

  • yarn typecheck — 0 errors
  • yarn lint — 0 errors
  • yarn test133/133 pass (5 suites; v5 paywall tests removed, default() contract tests added)
  • build-android / build-iosblocked by an external dependency, not by this PR: the native Purchasely 6.0.0 SDKs are not published yet, so Gradle cannot resolve io.purchasely:core:6.0.0 and the iOS pod is missing the v6 symbols (PLYWebCheckoutProvider*, asDictionary). These jobs have failed identically on every commit of this branch (pre‑existing) and will go green once the native 6.0.0 SDKs are released. The native changes here were verified structurally (single @implementation/module, balanced braces, wire‑name sync) since they cannot be compiled in CI yet.
  • Native iOS XCTest + Android JUnit suites (require example app context)

Reference

  • Migration guide: MIGRATION-v6.md
  • Changelog: packages/purchasely/CHANGELOG.md

🤖 Generated with Claude Code

@greptile-apps

greptile-apps Bot commented May 28, 2026

Copy link
Copy Markdown

Greptile Summary

This PR introduces the v6 cross-platform contract on top of the existing React Native Purchasely SDK: a new PresentationBuilder/PresentationRequest JS façade, typed interceptAction, a PurchaselyBuilder start chain, an Android bridge (PurchaselyV6Bridge), and an iOS bridge (PurchaselyRNV6.m/.h).

  • New v6 JS façade (src/v6/) exports PresentationBuilder, PresentationRequest, typed interceptors, and PurchaselyBuilder — all co-existing alongside v5 exports.
  • Android (PurchaselyV6Bridge) wires native v6 DSL APIs with ConcurrentHashMap state, a 30 s interceptor timeout, and @JvmStatic delegation from PurchaselyModule. Several v5 presentation methods (fetchPresentation, presentPresentation, and five others) have been replaced with immediate rejections on Android, contradicting the stated "no removal yet" policy and breaking existing v5 callers at runtime.
  • iOS (PurchaselyRNV6.m) implements the contract as a category on PurchaselyRN, with @synchronized(kV6StateLock) guards on shared mutable state. The interceptor callback store has no timeout — unlike Android's 30 s guard — so a JS bridge reload before v6CompleteInterceptor is called permanently freezes the native SDK action.

Confidence Score: 4/5

Safe to merge with caution — two active defects need resolution before shipping to existing v5 consumers or enabling the interceptor on iOS.

The Android bridge hard-removes v5 presentation methods that the JS deprecated wrappers still delegate to, meaning any app that hasn't migrated will receive runtime rejections on Android today. The iOS interceptor callback store has no timeout, so a bridge reload while an action is pending will freeze the native SDK action permanently — unlike the Android bridge which already has a 30 s guard.

packages/purchasely/android/src/main/java/com/reactnativepurchasely/PurchaselyModule.kt (v5 method removal) and packages/purchasely/ios/PurchaselyRNV6.m (missing interceptor timeout).

Important Files Changed

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)
Loading

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

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

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 and others added 15 commits June 16, 2026 11:24
…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>
@kherembourg kherembourg force-pushed the feat/sdk-v6-migration branch from 2177306 to 40faea8 Compare June 16, 2026 09:25
kherembourg and others added 6 commits June 16, 2026 17:40
## What

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).

### Dependency bumps
- `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**

### Native / config
- 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`.

### RN 0.86 breaking-change fixes
- 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`.

### Verification
- ✅ `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)

### Follow-up
- 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>
kherembourg and others added 9 commits June 23, 2026 21:32
v6 renames the native global handler for presentations the app does not
instantiate itself (campaigns, deeplinks, Promoted In-App Purchases) from
`setDefaultPresentationResultHandler` (block: result, plan) to
`setDefaultPresentationDismissHandler` (single rich `PLYPresentationOutcome`).
Expose the same name and a rich outcome cross-platform, mirroring Flutter.

- TS: `Purchasely.setDefaultPresentationDismissHandler(outcome => …)` returns
  an `EmitterSubscription`; `removeDefaultPresentationDismissHandler()` clears
  it. A single global handler is kept (re-registering replaces it), matching
  the native contract. Outcomes flow over a dedicated
  `PURCHASELY_DEFAULT_PRESENTATION_DISMISSED` event (no requestId) reusing the
  existing outcome mapping. Add `interactiveDismiss` to `CloseReason`.
- Android: rename the bridge method, call the renamed native handler, emit the
  rich outcome, and drop the now-dead legacy `{result, plan}` projection
  (`sendPurchaseResult` + the shared promises).
- iOS: add the bridge method, emit the rich outcome (with `closeReason`). The
  native selector is forward-declared and guarded with `respondsToSelector:`
  so the bridge keeps compiling against SDK builds that predate the rename.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- TS integration: register → emit DEFAULT_DISMISSED → assert the rich outcome
  (purchaseResult, closeReason incl. interactiveDismiss, plan, presentation);
  single-handler replacement; removeDefaultPresentationDismissHandler stops
  deliveries. Update the two native-module mocks to the renamed method.
- iOS: assert the event is in supportedEvents and the bridge method is exported.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Distinguish the two paywall flows in the migration guide: paywalls the app
displays (read the result from the request) vs. paywalls the SDK opens itself
(campaigns/deeplinks/Promoted IAP) handled by the new global
`setDefaultPresentationDismissHandler`. Update the v5→v6 mapping table and note
the cross-platform `closeReason` superset (backSystem / interactiveDismiss).

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

Mirror the native iOS/Android v6 SDK contract on the React Native surface:

- Rename PresentationOutcome -> PLYPresentationOutcome (matches native
  PLYPresentationOutcome on both platforms + the PLY public-type convention).
- Deeplink: rename isDeeplinkHandled -> handleDeeplink end to end (JS, iOS
  RCT_EXPORT_METHOD, Android @ReactMethod), matching native handleDeeplink.
- closeReason: drop the parasite `interactiveDismiss` value; the union is now
  { button, backSystem, programmatic } | null, mirroring PLYCloseReason. iOS
  interactive dismiss maps to backSystem for parity with Android system back.
  Also wire closeReason through the iOS dismiss outcome (it was dropped before).
- Transition (breaking): remove the legacy heightPercentage; add width/height as
  PLYTransitionDimension { type: 'pixel' | 'percentage', value }, mirroring the
  native PLYTransition. Android bridge builds PLYTransitionDimension accordingly.

Tests, mocks, migration guide, public doc and example comments updated.
typecheck + lint + 140 jest tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Implements a 10-test E2E suite that drives the JS public API through the
native Android bridge against the real Purchasely backend (mirror of the
Flutter integration tests documented in Flutter/E2E_TEST_INDEX.md).

Tests:
  T1  getAnonymousUserId
  T2  isAnonymous: true → login → false → logout → true
  T3  preload(placement) → typed Presentation
  T4  getDynamicOfferings
  T5  allProducts
  T6  synchronize() (expected to reject on emulator without billing)
  T7  interceptAction register/removeActionInterceptor/removeAll round-trip
  T8  display(drawer 60%) → sleep → close() → outcome.closeReason
  T9  purchase interceptor fires on real UIAutomator tap
  T10 setDefaultPresentationDismissHandler via deeplink + system BACK

Key fixes:
  - MainActivity.onPause() is a no-op in E2E mode: prevents React Native
    New Architecture (Bridgeless/Hermes) from suspending the JS timer queue
    when PLYFlowActivity takes focus, which would freeze all JS awaits.
  - index.js routes to E2ETestRunner when initialProp e2eMode=true is set
    by MainActivity.getLaunchOptions() (workaround for Bridgeless singleTask
    ignoring getMainComponentName() on onNewIntent).
  - run_e2e.sh: ASCII-only, LOGCAT_PID guard, uninstall-before-install.
  - T8/T9 use sleep(3000) instead of waitFor(onPresented) because the SDK
    beta.12 does not reliably fire the onPresented callback after display().

Verified: all 10 tests pass on emulator-5554 (Android 16, API 36).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- New `.github/workflows/e2e-android.yml`: runs T1-T10 on macos-latest
  (arm64) via reactivecircus/android-emulator-runner (API 34, arm64-v8a).
  Triggers: workflow_dispatch + nightly cron at 02:00 UTC.
  Uploads logcat on failure.
- Add `--debug` flag to `run_e2e.sh` to use the debug APK (no signing
  required in CI) and `assembleDebug` instead of assembleRelease.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace 6.0.0-beta.12 (unpublished, local-only) with 6.0.0-rc.2 across
all Android packages (core, google-play, huawei-services, amazon, player).
All five artifacts confirmed on Maven Central.

Also updates the E2E CI push trigger to fire on build.gradle and
integration_test changes (not just the workflow file itself).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
macos-latest runners (Apple Silicon) return HV_UNSUPPORTED when the
Android emulator tries to use Hypervisor.framework — the runner VMs
lack the hypervisor entitlement. The emulator falls back to software
mode and never boots within the 10 min timeout.

ubuntu-latest supports KVM hardware acceleration: emulator boots in
~2 min, x86_64 system image (API 34) is well supported.
iOS E2E will use a separate macos-latest job when added.

Co-Authored-By: Claude Sonnet 4.6 <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.

2 participants