feat: Custom Screens (BYOS) support for React Native - #260
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>
- 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>
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>
….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>
…med, not removed The v6 migration wrongly removed the client (BYOS) presentation API. The native SDKs keep it: Android is unchanged, iOS only renamed the native call (clientPresentationOpened → clientPresentationDisplayed). Restore both methods at every layer under the same v5 public names: - TS: clientPresentationDisplayed/Closed(presentation) — sends only the identifiers (screenId, placementId) since a PLYLoadedPresentation also carries functions the RN bridge cannot serialize. - iOS: v6 PLYPresentation is a protocol that cannot be rebuilt from a JS dictionary, so the bridge resolves the loaded native instance from the per-request registry (kPresentationsByRequest) by screenId (fallback placementId). Calls the new clientPresentationDisplayedWith: selector, guarded with respondsToSelector: + fallback to the rc.2 clientPresentationOpenedWith: (same pattern as the default dismiss handler). - Android: same resolution against activeLoadedPresentations, then the unchanged Purchasely.clientPresentationDisplayed/Closed. - Unresolvable presentation → clear warning + no-op on both platforms. - Docs: MIGRATION-v6.md and CHANGELOG.md now document the API as kept. - Tests: index.test.ts asserts the API exists and forwards the identifiers. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…allbacks rc.3 ships the renamed iOS entry points, so the bridge now calls clientPresentationDisplayedWith: and setDefaultPresentationDismissHandler: directly — the respondsToSelector: guards, the rc.2 clientPresentationOpenedWith: fallback and both forward-declaration categories are removed. Pins bumped to 6.0.0-rc.3: RN podspec (pod Purchasely), Android gradle (io.purchasely core / google-play / amazon / huawei-services / player), example Podfile.lock version strings (checksums will be rewritten by pod install once rc.3 is published), CLAUDE.md native-deps notes. Wrapper versions (package.json, bridgeVersion, VERSIONS.md history) are untouched — they track the RN package release, not the native pin. Note: native builds will not compile/resolve until the native 6.0.0-rc.3 artifacts are published. JS tests, typecheck and lint all pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Native never nests `requestId` inside the presentation payload (`toRNMap`/`presentationToMap` don't emit it); `requestId` is stamped on by `buildLoadedPresentation` for preloaded presentations. The `raw.requestId ?? null` normalization branch was therefore always null. Full jest suite (151) + typecheck green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…uard - Add customScreen.test.ts covering the presentationKey fallback (customScreenId -> requestId), precedence, and the warn/no-op branch when a presentation carries neither key (#3 testing gap, #10 in review). - Factor the resolve-key + null-check + warn shape shared by executeConnection/customScreenBack/customScreenClose into one withPresentationKey helper (#10 duplication). jest 93 pass, typecheck clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Cross-platform review fixes on the native bridges. NOT compiled or run in the review environment — needs a native build + the old/new-arch matrix before merge. Android (PurchaselyCustomScreenFragment.kt, PurchaselyModule.kt): - Guard the onResume/onPause `application as ReactApplication` cast in a runCatching+warn-log (onCreateView already guards the identical cast), so an unexpected host shape degrades instead of crashing the host app (#1). - Clear the customScreenPresentations registry in removeCustomScreenProvider() so entries never mounted / missed by the teardown guard don't leak across register-unregister cycles. The config-change-aware onDestroy guard is left intact deliberately: unconditional onDestroyView removal would break rotation survival (the recreated fragment re-mounts with the same customScreenId) (#6). iOS (PurchaselyRN.m): - Reject whitespace-only component names (trim before the length check) to match Android's isBlank() (#9). - Sort marshalled connections by id: iOS delivers an unordered Set, so index-based rendering was non-deterministic and diverged from Android's ordered List (#11). Not applied (need design input / runtime verification): mount-failure fallback UI (#2), host-lifecycle scoping (#5), root-view reflection -> typed API (#8), native unit tests (#3), iOS isDefault (blocked on native SDK, #7). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Greptile SummaryThis PR adds app-owned React Native screens inside Purchasely flows. The main changes are:
|
| Filename | Overview |
|---|---|
| packages/purchasely/android/src/main/java/com/reactnativepurchasely/PurchaselyCustomScreenFragment.kt | Adds legacy and bridgeless React hosting, but directly references APIs outside the package's unrestricted React Native compatibility range. |
| packages/purchasely/android/src/main/java/com/reactnativepurchasely/PurchaselyModule.kt | Adds provider registration, presentation lookup, navigation, and connection marshalling; asynchronous provider removal can race native requests. |
| packages/purchasely/ios/PurchaselyRN.m | Adds the iOS provider delegate, root-view host, presentation registry, navigation methods, and connection marshalling. |
| packages/purchasely/src/customScreen.ts | Adds provider registration, presentation-key navigation helpers, and the Custom Screen hook. |
| packages/purchasely/src/presentation.ts | Normalizes connections and Custom Screen identifiers and attaches request IDs to preloaded presentations. |
| packages/purchasely/src/presentationTypes.ts | Defines the public Custom Screen provider, props, connection, and presentation-key types. |
Sequence Diagram
sequenceDiagram
participant App as React Native app
participant JS as Purchasely JS API
participant Bridge as Native bridge
participant SDK as Purchasely SDK
participant Host as Native screen host
App->>JS: Register component name
JS->>Bridge: setCustomScreenProvider(name)
Bridge->>SDK: Install provider
SDK->>Bridge: Request custom presentation
Bridge->>Bridge: Store by customScreenId
Bridge->>Host: Create Fragment or UIViewController
Host->>App: Render component with presentation props
App->>JS: Execute connection, back, or close
JS->>Bridge: Send presentation key and action
Bridge->>SDK: Resolve presentation and navigate
Host->>Bridge: Release registry entry on teardown
Prompt To Fix All With AI
Fix the following 2 code review issues. Work through them one at a time, proposing concise fixes.
---
### Issue 1 of 2
packages/purchasely/android/src/main/java/com/reactnativepurchasely/PurchaselyCustomScreenFragment.kt:13
**Older React Native Builds Break**
Every Android consumer compiles this file, but `ReactNativeFeatureFlags` and the `ReactHost`/`ReactSurface` calls require newer React Native APIs while the package still allows `react-native: "*"`. Apps on an older supported React Native version can therefore fail with unresolved references when upgrading this package, even if they never register a Custom Screen.
### Issue 2 of 2
packages/purchasely/android/src/main/java/com/reactnativepurchasely/PurchaselyModule.kt:895-899
**Provider Removal Races New Requests**
This clears the registry before the UI-thread task uninstalls the provider. If the native flow requests a Custom Screen between those operations, the old provider registers another presentation after the clear, and the method has already returned to JS; callers can then display a supposedly removed provider or retain a stale registry entry.
Reviews (1): Last reviewed commit: "fix(review): harden native custom-screen..." | Re-trigger Greptile
| import com.facebook.react.ReactApplication | ||
| import com.facebook.react.ReactRootView | ||
| import com.facebook.react.interfaces.fabric.ReactSurface | ||
| import com.facebook.react.internal.featureflags.ReactNativeFeatureFlags |
There was a problem hiding this comment.
Older React Native Builds Break
Every Android consumer compiles this file, but ReactNativeFeatureFlags and the ReactHost/ReactSurface calls require newer React Native APIs while the package still allows react-native: "*". Apps on an older supported React Native version can therefore fail with unresolved references when upgrading this package, even if they never register a Custom Screen.
Context Used: CLAUDE.md (source)
Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/purchasely/android/src/main/java/com/reactnativepurchasely/PurchaselyCustomScreenFragment.kt
Line: 13
Comment:
**Older React Native Builds Break**
Every Android consumer compiles this file, but `ReactNativeFeatureFlags` and the `ReactHost`/`ReactSurface` calls require newer React Native APIs while the package still allows `react-native: "*"`. Apps on an older supported React Native version can therefore fail with unresolved references when upgrading this package, even if they never register a Custom Screen.
**Context Used:** CLAUDE.md ([source](https://app.greptile.com/purchasely/github/purchasely/purchasely-reactnative/-/custom-context?memory=774b1053-2ac2-46c7-8406-b2182cd63ef6))
How can I resolve this? If you propose a fix, please make it concise.Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
| // Drop any presentations captured under the outgoing provider so entries | ||
| // never mounted (or missed by the Fragment teardown guard) don't accumulate | ||
| // across register/unregister cycles. | ||
| customScreenPresentations.clear() | ||
| UiThreadUtil.runOnUiThread { Purchasely.setCustomScreenProvider(null) } |
There was a problem hiding this comment.
Provider Removal Races New Requests
This clears the registry before the UI-thread task uninstalls the provider. If the native flow requests a Custom Screen between those operations, the old provider registers another presentation after the clear, and the method has already returned to JS; callers can then display a supposedly removed provider or retain a stale registry entry.
Context Used: CLAUDE.md (source)
Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/purchasely/android/src/main/java/com/reactnativepurchasely/PurchaselyModule.kt
Line: 895-899
Comment:
**Provider Removal Races New Requests**
This clears the registry before the UI-thread task uninstalls the provider. If the native flow requests a Custom Screen between those operations, the old provider registers another presentation after the clear, and the method has already returned to JS; callers can then display a supposedly removed provider or retain a stale registry entry.
**Context Used:** CLAUDE.md ([source](https://app.greptile.com/purchasely/github/purchasely/purchasely-reactnative/-/custom-context?memory=774b1053-2ac2-46c7-8406-b2182cd63ef6))
How can I resolve this? If you propose a fix, please make it concise.f6bc9ce to
6ea05c6
Compare
Custom Screens (BYOS) for React Native
Lets an app render its own React Native screen as one step inside a Purchasely native flow — Purchasely still owns the flow (transitions, ordering, navigation); the app owns just that one screen. Mirrors the shipped native Android/iOS Custom Screen feature.
Base:
feat/sdk-v6-migration· Tickets: MOB-204 (parent MOB-200).How it works (the reviewer's mental model)
AppRegistry.registerComponent('PurchaselyCustomScreen', …)— and tells the SDK its name:setCustomScreenProvider({ componentName }).is_client) step, the SDK calls the provider back. The module packages thepresentationinto a Bundle, mints acustomScreenId, and returns a Fragment (Android) / UIViewController (iOS).initialProps.executeConnection/customScreenBack/customScreenClose; the module resolves the presentation bycustomScreenId(orrequestIdfor the standalone path) and drives the native flow.Net effect: React never sends a "component" over the bridge — it sends a name, and each platform's host turns that name back into rendered UI inside the flow's own container.
Where to look
src/customScreen.ts,src/presentationTypes.ts,src/presentation.tsPurchaselyCustomScreenFragment.kt; provider + connection methods inPurchaselyModule.ktPLYRNCustomScreenViewController+ methods inPurchaselyRN.mStatus
Multi-agent code review run — no P0/P1 blockers. Review fixes applied on top (
fix(review)/test(review)commits: dead-code cleanup, guard dedup + added jest coverage, native crash-guard, registry cleanup, iOS blank-name + connection-ordering parity).Before release: E2E scenario in
E2ETestRunner+ old/new-arch matrix run, native unit tests (JUnit/XCTest), and a native build to verify the native review fixes (they compile-clean by inspection but weren't built in review). Design follow-ups noted in the diff.🤖 Generated with Claude Code