From 533e79a97e69ea5796a8099dd81758525ad5ae7a Mon Sep 17 00:00:00 2001 From: Kevin Date: Thu, 28 May 2026 18:37:26 +0200 Subject: [PATCH 01/77] =?UTF-8?q?feat(types):=20add=20v6=20cross-platform?= =?UTF-8?q?=20fa=C3=A7ade=20(Presentation=20builder,=20outcome,=20intercep?= =?UTF-8?q?tor)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- packages/purchasely/package.json | 2 +- .../purchasely/src/__tests__/index.test.ts | 4 +- packages/purchasely/src/index.ts | 57 ++- packages/purchasely/src/v6/events.ts | 45 ++ packages/purchasely/src/v6/index.ts | 17 + packages/purchasely/src/v6/interceptor.ts | 169 +++++++ packages/purchasely/src/v6/presentation.ts | 452 ++++++++++++++++++ packages/purchasely/src/v6/startBuilder.ts | 153 ++++++ packages/purchasely/src/v6/types.ts | 196 ++++++++ 9 files changed, 1091 insertions(+), 4 deletions(-) create mode 100644 packages/purchasely/src/v6/events.ts create mode 100644 packages/purchasely/src/v6/index.ts create mode 100644 packages/purchasely/src/v6/interceptor.ts create mode 100644 packages/purchasely/src/v6/presentation.ts create mode 100644 packages/purchasely/src/v6/startBuilder.ts create mode 100644 packages/purchasely/src/v6/types.ts diff --git a/packages/purchasely/package.json b/packages/purchasely/package.json index 9c8d0591..33b16de3 100644 --- a/packages/purchasely/package.json +++ b/packages/purchasely/package.json @@ -1,7 +1,7 @@ { "name": "react-native-purchasely", "title": "Purchasely React Native", - "version": "5.7.3", + "version": "6.0.0", "description": "Purchasely is a solution to ease the integration and boost your In-App Purchase & Subscriptions on the App Store, Google Play Store and Huawei App Gallery.", "source": "./src/index.tsx", "main": "./lib/commonjs/index.js", diff --git a/packages/purchasely/src/__tests__/index.test.ts b/packages/purchasely/src/__tests__/index.test.ts index 93bcd6a3..408955ca 100644 --- a/packages/purchasely/src/__tests__/index.test.ts +++ b/packages/purchasely/src/__tests__/index.test.ts @@ -182,7 +182,7 @@ describe('Purchasely SDK', () => { 'test-user', mockConstants.logLevelDebug, mockConstants.runningModeFull, - '5.7.3' + '6.0.0' ) }) @@ -203,7 +203,7 @@ describe('Purchasely SDK', () => { null, mockConstants.logLevelError, mockConstants.runningModeFull, - '5.7.3' + '6.0.0' ) }) diff --git a/packages/purchasely/src/index.ts b/packages/purchasely/src/index.ts index 32c340e3..cb7a4f0c 100644 --- a/packages/purchasely/src/index.ts +++ b/packages/purchasely/src/index.ts @@ -27,13 +27,26 @@ import type { PurchaselySubscription, PurchaselyUserAttribute, } from './types'; +import { + PresentationBuilder, + PurchaselyBuilder, + interceptAction, + removeActionInterceptor, + removeAllActionInterceptors, +} from './v6'; +import type { PresentationActionKind } from './v6'; -const purchaselyVersion = '5.7.3'; +const purchaselyVersion = '6.0.0'; const constants = NativeModules.Purchasely.getConstants() as Constants; const PurchaselyEventEmitter = new NativeEventEmitter(NativeModules.Purchasely); +/** + * @deprecated since v6.0.0 — use `Purchasely.builder(apiKey)` and chain the + * v6 options (`runningMode`, `allowDeeplink`, `allowCampaigns`, …). Kept for + * backward compatibility with v5 integrations. + */ const start = ({ apiKey, androidStores = ['Google'], @@ -53,6 +66,18 @@ const start = ({ ); }; +/** + * Cross-platform v6 start builder. Mirrors the iOS/Android contract: + * `Purchasely.builder('API_KEY').appUserId('u').runningMode('full').start()`. + * + * See: reports/v6-presentation-comparison-v3-claude/BRIDGE-CONTRACT.md + */ +const builder = (apiKey: string): PurchaselyBuilder => { + // Ensure the bridge version stays in sync with the wrapper version. + PurchaselyBuilder.bridgeVersion = purchaselyVersion; + return PurchaselyBuilder.apiKey(apiKey); +}; + function setUserAttributeWithDate(key: string, value: Date, legalBasis?: PLYDataProcessingLegalBasis): void { const dateAsString = value.toISOString(); return NativeModules.Purchasely.setUserAttributeWithDate(key, dateAsString, legalBasis); @@ -140,6 +165,11 @@ type PaywallActionInterceptorCallback = ( result: PaywallActionInterceptorResult ) => void; +/** + * @deprecated since v6.0.0 — use `Purchasely.interceptAction(kind, handler)`. + * The new API provides typed payloads and per-kind subscriptions. Kept for + * backward compatibility with v5 integrations. + */ const setPaywallActionInterceptorCallback = ( callback: PaywallActionInterceptorCallback ) => { @@ -153,6 +183,12 @@ const setPaywallActionInterceptorCallback = ( }); }; +/** + * @deprecated since v6.0.0 — use + * `PresentationBuilder.placement(id).build().preload()`. The v6 builder + * exposes a typed {@link Presentation} and supports the full lifecycle + * (`onLoaded`, `onPresented`, `onCloseRequested`, `onDismissed`). + */ const fetchPresentation = ({ placementId = null, presentationId = null, @@ -311,6 +347,12 @@ const setLogLevel = (logLevel: LogLevels): void => { return NativeModules.Purchasely.setLogLevel(logLevel); }; +/** + * @deprecated since v6.0.0 — use + * `Purchasely.builder(apiKey).allowDeeplink(true).start()`. + * + * Kept for backward compatibility with v5 integrations. + */ const readyToOpenDeeplink = (ready: boolean): void => { return NativeModules.Purchasely.readyToOpenDeeplink(ready); }; @@ -366,6 +408,10 @@ const setDefaultPresentationResultHandler = return NativeModules.Purchasely.setDefaultPresentationResultHandler(); }; +/** + * @deprecated since v6.0.0 — use `Purchasely.interceptAction(kind, handler)`. + * Kept for backward compatibility with v5 integrations. + */ const setPaywallActionInterceptor = (): Promise => { return NativeModules.Purchasely.setPaywallActionInterceptor(); @@ -490,6 +536,14 @@ const setDebugMode = (debugMode: boolean): void => { const Purchasely = { start, + builder, + presentation: PresentationBuilder, + interceptAction: ( + kind: PresentationActionKind, + handler: Parameters[1] + ) => interceptAction(kind, handler), + removeActionInterceptor, + removeAllActionInterceptors, addEventListener, removeEventListener, addPurchasedListener, @@ -564,6 +618,7 @@ const Purchasely = { export * from './types'; export * from './enums'; export * from './interfaces'; +export * from './v6'; export { PLYPresentationView }; export default Purchasely; diff --git a/packages/purchasely/src/v6/events.ts b/packages/purchasely/src/v6/events.ts new file mode 100644 index 00000000..a5580bc6 --- /dev/null +++ b/packages/purchasely/src/v6/events.ts @@ -0,0 +1,45 @@ +import { NativeEventEmitter, NativeModules } from 'react-native'; + +/** + * Native event names used for the v6 presentation lifecycle and interceptors. + * The Android and iOS bridges emit these names with a payload describing the + * pending request id and call-specific data. + * + * @internal + */ +export const PURCHASELY_V6_EVENTS = { + /** Presentation finished loading (resolves a `preload()` Promise). */ + LOADED: 'PURCHASELY_V6_LOADED', + /** Presentation became visible to the user (`onPresented` callback). */ + PRESENTED: 'PURCHASELY_V6_PRESENTED', + /** User requested closing the presentation (`onCloseRequested`). */ + CLOSE_REQUESTED: 'PURCHASELY_V6_CLOSE_REQUESTED', + /** Presentation dismissed — resolves the `display()` Promise. */ + DISMISSED: 'PURCHASELY_V6_DISMISSED', + /** Action interceptor fired and is awaiting an InterceptResult. */ + ACTION_INTERCEPTED: 'PURCHASELY_V6_ACTION_INTERCEPTED', +} as const; + +/** @internal */ +export const purchaselyV6EventEmitter = new NativeEventEmitter( + NativeModules.Purchasely +); + +/** Shape of every v6 lifecycle event payload. */ +export interface V6LifecycleEvent { + requestId: string; + presentation?: any; + error?: { code?: string | number | null; message: string; domain?: string | null } | null; + purchaseResult?: number | null; + plan?: any; + closeReason?: 'button' | 'backSystem' | 'programmatic' | null; +} + +/** Shape of an interceptor-triggered event sent from native. */ +export interface V6InterceptorEvent { + requestId: string; + callbackId: string; + kind: string; + info?: { contentId?: string | null; presentation?: any | null }; + payload?: any; +} diff --git a/packages/purchasely/src/v6/index.ts b/packages/purchasely/src/v6/index.ts new file mode 100644 index 00000000..701e3209 --- /dev/null +++ b/packages/purchasely/src/v6/index.ts @@ -0,0 +1,17 @@ +/** + * v6 public surface — exported from the package root via `react-native-purchasely`. + * The legacy v5 API remains available alongside this module for backward + * compatibility, but is marked `@deprecated`. + * + * Contract: reports/v6-presentation-comparison-v3-claude/BRIDGE-CONTRACT.md + */ + +export * from './types'; +export { PURCHASELY_V6_EVENTS } from './events'; +export { PresentationBuilder, PresentationRequest } from './presentation'; +export { + interceptAction, + removeActionInterceptor, + removeAllActionInterceptors, +} from './interceptor'; +export { PurchaselyBuilder } from './startBuilder'; diff --git a/packages/purchasely/src/v6/interceptor.ts b/packages/purchasely/src/v6/interceptor.ts new file mode 100644 index 00000000..71a717c8 --- /dev/null +++ b/packages/purchasely/src/v6/interceptor.ts @@ -0,0 +1,169 @@ +import { NativeModules } from 'react-native'; +import type { EmitterSubscription } from 'react-native'; + +import { + PURCHASELY_V6_EVENTS, + purchaselyV6EventEmitter, +} from './events'; +import type { V6InterceptorEvent } from './events'; +import type { + ActionPayload, + InterceptorHandler, + InterceptorInfo, + InterceptResult, + Presentation, + PresentationActionKind, +} from './types'; + +/** + * Registry of attached interceptors. Keyed by action kind so each kind can be + * subscribed at most once (matching the Android `interceptAction` semantics). + */ +const interceptorRegistry = new Map< + PresentationActionKind, + { subscription: EmitterSubscription; handler: InterceptorHandler } +>(); + +function normalizeInfo(raw: any): InterceptorInfo { + if (!raw) { + return {}; + } + return { + contentId: raw.contentId ?? null, + presentation: raw.presentation + ? ({ + screenId: raw.presentation.screenId ?? raw.presentation.id, + id: raw.presentation.screenId ?? raw.presentation.id, + placementId: raw.presentation.placementId ?? null, + contentId: raw.presentation.contentId ?? null, + type: raw.presentation.type ?? null, + } as Presentation) + : null, + }; +} + +function normalizePayload( + kind: PresentationActionKind, + raw: any +): ActionPayload | null { + if (!raw) { + if ( + kind === 'login' || + kind === 'restore' || + kind === 'promoCode' || + kind === 'close' || + kind === 'closeAll' + ) { + return null; + } + return null; + } + + switch (kind) { + case 'navigate': + return { + kind: 'navigate', + url: raw.url ?? '', + title: raw.title ?? null, + }; + case 'purchase': + return { + kind: 'purchase', + plan: raw.plan, + subscriptionOffer: raw.subscriptionOffer ?? null, + offer: raw.offer ?? null, + }; + case 'close': + case 'closeAll': + return { + kind, + closeReason: raw.closeReason ?? 'programmatic', + }; + case 'openPresentation': + return { + kind: 'openPresentation', + presentationId: raw.presentationId ?? raw.presentation ?? '', + }; + case 'openPlacement': + return { + kind: 'openPlacement', + placementId: raw.placementId ?? raw.placement ?? '', + }; + case 'webCheckout': + return { + kind: 'webCheckout', + url: raw.url ?? '', + clientReferenceId: raw.clientReferenceId ?? '', + queryParameterKey: raw.queryParameterKey ?? '', + webCheckoutProvider: raw.webCheckoutProvider ?? 'other', + }; + default: + return null; + } +} + +/** + * Register a typed interceptor for a given presentation action. + * + * The handler returns an {@link InterceptResult} indicating whether the SDK + * should consider the action handled by the host app. + * + * @example + * ```ts + * Purchasely.interceptAction('navigate', async ({ presentation }, payload) => { + * if (payload?.kind === 'navigate') { + * Linking.openURL(payload.url); + * return 'success'; + * } + * return 'notHandled'; + * }); + * ``` + */ +export function interceptAction( + kind: PresentationActionKind, + handler: InterceptorHandler +): void { + removeActionInterceptor(kind); + + const subscription = purchaselyV6EventEmitter.addListener( + PURCHASELY_V6_EVENTS.ACTION_INTERCEPTED, + async (event: V6InterceptorEvent) => { + if (event.kind !== kind) { + return; + } + const info = normalizeInfo(event.info); + const payload = normalizePayload(kind, event.payload); + let result: InterceptResult = 'notHandled'; + try { + result = await handler(info, payload); + } catch (e) { + result = 'failed'; + } + NativeModules.Purchasely.v6CompleteInterceptor( + event.callbackId, + result + ); + } + ); + + interceptorRegistry.set(kind, { subscription, handler }); + + NativeModules.Purchasely.v6RegisterInterceptor(kind); +} + +/** Remove a specific action interceptor. */ +export function removeActionInterceptor(kind: PresentationActionKind): void { + const entry = interceptorRegistry.get(kind); + if (entry) { + entry.subscription.remove(); + interceptorRegistry.delete(kind); + } + NativeModules.Purchasely.v6UnregisterInterceptor(kind); +} + +/** Remove every previously-registered action interceptor. */ +export function removeAllActionInterceptors(): void { + for (const kind of Array.from(interceptorRegistry.keys())) { + removeActionInterceptor(kind); + } +} diff --git a/packages/purchasely/src/v6/presentation.ts b/packages/purchasely/src/v6/presentation.ts new file mode 100644 index 00000000..458e039d --- /dev/null +++ b/packages/purchasely/src/v6/presentation.ts @@ -0,0 +1,452 @@ +import { NativeModules } from 'react-native'; +import type { EmitterSubscription } from 'react-native'; + +import type { + Presentation, + PresentationError, + PresentationOutcome, + Transition, +} from './types'; +import { purchaseResultFromOrdinal } from './types'; +import { PURCHASELY_V6_EVENTS, purchaselyV6EventEmitter } from './events'; +import type { V6LifecycleEvent } from './events'; + +/** Counter for generating bridge request ids. */ +let nextRequestId = 0; +const generateRequestId = (): string => { + nextRequestId += 1; + return `v6_req_${Date.now()}_${nextRequestId}`; +}; + +/** Normalize a native presentation payload to the v6 {@link Presentation} shape. */ +function normalizePresentation(raw: any): Presentation | null { + if (!raw || typeof raw !== 'object') { + return null; + } + + // The native bridges emit `id` to stay backwards-compatible. + // We map it to `screenId` (cf. P1.1). + const screenId = raw.screenId ?? raw.id; + if (!screenId) { + return null; + } + + return { + screenId, + id: screenId, + placementId: raw.placementId ?? null, + contentId: raw.contentId ?? null, + audienceId: raw.audienceId ?? null, + abTestId: raw.abTestId ?? null, + abTestVariantId: raw.abTestVariantId ?? null, + language: raw.language ?? null, + type: raw.type ?? null, + plans: raw.plans ?? null, + metadata: raw.metadata ?? null, + height: raw.height ?? null, + }; +} + +/** Normalize a native error payload to the v6 {@link PresentationError} shape. */ +function normalizeError(raw: any): PresentationError | null { + if (!raw) { + return null; + } + if (typeof raw === 'string') { + return { message: raw }; + } + return { + code: raw.code ?? null, + message: raw.message ?? 'Unknown error', + domain: raw.domain ?? null, + }; +} + +/** Convert a native lifecycle event into a {@link PresentationOutcome}. */ +function eventToOutcome( + event: V6LifecycleEvent, + presentation: Presentation | null +): PresentationOutcome { + const error = normalizeError(event.error); + return { + presentation, + purchaseResult: purchaseResultFromOrdinal(event.purchaseResult), + plan: event.plan ?? null, + // Exclusion rule (cf. contract): error != null ⇒ closeReason == null. + closeReason: error ? null : event.closeReason ?? null, + error, + }; +} + +/** + * Holds the callbacks registered on a {@link PresentationBuilder}. They are + * shared between the builder, the request and the live {@link Presentation} + * so that callbacks reassigned after `preload()` take effect. + */ +interface PresentationCallbacks { + onLoaded?: ( + presentation: Presentation, + error?: PresentationError | null + ) => void; + onPresented?: ( + presentation?: Presentation | null, + error?: PresentationError | null + ) => void; + onCloseRequested?: () => void; + onDismissed?: (outcome: PresentationOutcome) => void; +} + +interface BuilderConfig { + placementId?: string | null; + screenId?: string | null; + isDefault?: boolean; + contentId?: string | null; + backgroundColor?: string | null; + progressColor?: string | null; + displayCloseButton?: boolean | null; + displayBackButton?: boolean | null; + callbacks: PresentationCallbacks; +} + +/** + * Cross-platform v6 builder. Mirrors the Android/iOS builder API while hiding + * the platform-specific bridge wiring. + * + * @example + * ```ts + * const request = PresentationBuilder.placement('ONBOARDING') + * .onDismissed((outcome) => console.log(outcome)) + * .build(); + * + * const outcome = await request.display(); + * ``` + */ +export class PresentationBuilder { + /** @internal */ + private readonly config: BuilderConfig; + + private constructor(config: BuilderConfig) { + this.config = config; + } + + /** Build a request that targets a placement vendor id. */ + static placement(placementId: string): PresentationBuilder { + return new PresentationBuilder({ + placementId, + callbacks: {}, + }); + } + + /** + * Build a request that targets a specific presentation by its screen id. + * On iOS this maps to `PLYPresentationBuilder.from(presentationId:)`. + */ + static screen(screenId: string): PresentationBuilder { + return new PresentationBuilder({ + screenId, + callbacks: {}, + }); + } + + /** Build a request that uses the SDK's default placement. */ + static default(): PresentationBuilder { + return new PresentationBuilder({ + isDefault: true, + callbacks: {}, + }); + } + + contentId(id: string | null): this { + this.config.contentId = id; + return this; + } + + backgroundColor(hex: string | null): this { + this.config.backgroundColor = hex; + return this; + } + + progressColor(hex: string | null): this { + this.config.progressColor = hex; + return this; + } + + /** + * Android only — no-op on iOS until native exposes the property. + */ + displayCloseButton(show: boolean): this { + this.config.displayCloseButton = show; + return this; + } + + /** + * Android only — no-op on iOS until native exposes the property. + */ + displayBackButton(show: boolean): this { + this.config.displayBackButton = show; + return this; + } + + onLoaded( + handler: ( + presentation: Presentation, + error?: PresentationError | null + ) => void + ): this { + this.config.callbacks.onLoaded = handler; + return this; + } + + onPresented( + handler: ( + presentation?: Presentation | null, + error?: PresentationError | null + ) => void + ): this { + this.config.callbacks.onPresented = handler; + return this; + } + + onCloseRequested(handler: () => void): this { + this.config.callbacks.onCloseRequested = handler; + return this; + } + + onDismissed(handler: (outcome: PresentationOutcome) => void): this { + this.config.callbacks.onDismissed = handler; + return this; + } + + /** Convert the builder into a runnable {@link PresentationRequest}. */ + build(): PresentationRequest { + return new PresentationRequest(this.config); + } +} + +/** + * Encapsulates a v6 presentation request: it can be preloaded (without UI), + * or displayed (which resolves at dismiss). + */ +export class PresentationRequest { + /** @internal */ + private readonly config: BuilderConfig; + /** @internal */ + private requestId: string | null = null; + /** @internal */ + private subscriptions: EmitterSubscription[] = []; + /** @internal */ + private livePresentation: Presentation | null = null; + + constructor(config: BuilderConfig) { + this.config = config; + } + + /** + * Preload the presentation. Resolves once the SDK reports the screen + * is loaded (`onLoaded`). Rejects if the SDK fails before load. + */ + preload(): Promise { + const requestId = this.ensureRequestId(); + return new Promise((resolve, reject) => { + const loadedSubscription = + purchaselyV6EventEmitter.addListener( + PURCHASELY_V6_EVENTS.LOADED, + (event: V6LifecycleEvent) => { + if (event.requestId !== requestId) { + return; + } + loadedSubscription.remove(); + const presentation = normalizePresentation( + event.presentation + ); + const error = normalizeError(event.error); + if (this.config.callbacks.onLoaded && presentation) { + this.config.callbacks.onLoaded(presentation, error); + } + if (error || !presentation) { + reject(error ?? { message: 'Preload failed' }); + return; + } + this.livePresentation = presentation; + resolve(presentation); + } + ); + this.subscriptions.push(loadedSubscription); + + NativeModules.Purchasely.v6Preload( + requestId, + this.toNativePayload() + ).catch((nativeError: any) => { + loadedSubscription.remove(); + reject(normalizeError(nativeError)); + }); + }); + } + + /** + * Display the presentation. Resolves at DISMISS with a + * {@link PresentationOutcome} (cf. contract P0.3). Subscribers can attach + * their own `onPresented` / `onCloseRequested` callbacks via the builder. + */ + display(transition?: Transition | null): Promise { + const requestId = this.ensureRequestId(); + + // Allow multiple `display()` on the same request — clean up first. + this.teardownSubscriptions(); + + return new Promise((resolve) => { + this.bindLifecycleEvents(requestId, resolve); + + NativeModules.Purchasely.v6Display( + requestId, + this.toNativePayload(), + transition ?? null + ).catch((nativeError: any) => { + const error = normalizeError(nativeError); + // Synthesize an outcome so consumers always receive one. + const outcome: PresentationOutcome = { + presentation: this.livePresentation, + purchaseResult: null, + plan: null, + closeReason: null, + error: error ?? { message: 'Display failed' }, + }; + if (this.config.callbacks.onPresented) { + this.config.callbacks.onPresented(null, outcome.error); + } + if (this.config.callbacks.onDismissed) { + this.config.callbacks.onDismissed(outcome); + } + resolve(outcome); + this.teardownSubscriptions(); + }); + }); + } + + /** + * Replace the dismissed-callback after `preload()` / `display()`. Useful + * for hot-swapping callbacks on a cached {@link Presentation}. + */ + onDismissed( + handler: (outcome: PresentationOutcome) => void + ): this { + this.config.callbacks.onDismissed = handler; + return this; + } + + onPresented( + handler: ( + presentation?: Presentation | null, + error?: PresentationError | null + ) => void + ): this { + this.config.callbacks.onPresented = handler; + return this; + } + + onCloseRequested(handler: () => void): this { + this.config.callbacks.onCloseRequested = handler; + return this; + } + + /** Programmatically close the presentation if it is currently visible. */ + close(): void { + if (!this.requestId) { + return; + } + NativeModules.Purchasely.v6Close(this.requestId); + } + + /** Navigate back inside a multi-step (Flow) presentation. */ + back(): void { + if (!this.requestId) { + return; + } + NativeModules.Purchasely.v6Back(this.requestId); + } + + private ensureRequestId(): string { + if (!this.requestId) { + this.requestId = generateRequestId(); + } + return this.requestId; + } + + private bindLifecycleEvents( + requestId: string, + resolve: (outcome: PresentationOutcome) => void + ): void { + const onPresented = purchaselyV6EventEmitter.addListener( + PURCHASELY_V6_EVENTS.PRESENTED, + (event: V6LifecycleEvent) => { + if (event.requestId !== requestId) { + return; + } + const presentation = + normalizePresentation(event.presentation) ?? + this.livePresentation; + if (presentation) { + this.livePresentation = presentation; + } + const error = normalizeError(event.error); + if (this.config.callbacks.onPresented) { + this.config.callbacks.onPresented( + presentation, + error ?? null + ); + } + } + ); + const onCloseRequested = purchaselyV6EventEmitter.addListener( + PURCHASELY_V6_EVENTS.CLOSE_REQUESTED, + (event: V6LifecycleEvent) => { + if (event.requestId !== requestId) { + return; + } + if (this.config.callbacks.onCloseRequested) { + this.config.callbacks.onCloseRequested(); + } + } + ); + const onDismissed = purchaselyV6EventEmitter.addListener( + PURCHASELY_V6_EVENTS.DISMISSED, + (event: V6LifecycleEvent) => { + if (event.requestId !== requestId) { + return; + } + const presentation = + normalizePresentation(event.presentation) ?? + this.livePresentation; + const outcome = eventToOutcome(event, presentation); + if (this.config.callbacks.onDismissed) { + this.config.callbacks.onDismissed(outcome); + } + resolve(outcome); + this.teardownSubscriptions(); + } + ); + + this.subscriptions.push(onPresented, onCloseRequested, onDismissed); + } + + private teardownSubscriptions(): void { + for (const subscription of this.subscriptions) { + subscription.remove(); + } + this.subscriptions = []; + } + + private toNativePayload(): Record { + return { + placementId: this.config.placementId ?? null, + // Map `screenId` → native `presentationId` for the bridges. + presentationId: this.config.screenId ?? null, + isDefault: this.config.isDefault ?? false, + contentId: this.config.contentId ?? null, + backgroundColor: this.config.backgroundColor ?? null, + progressColor: this.config.progressColor ?? null, + displayCloseButton: this.config.displayCloseButton ?? null, + displayBackButton: this.config.displayBackButton ?? null, + }; + } +} diff --git a/packages/purchasely/src/v6/startBuilder.ts b/packages/purchasely/src/v6/startBuilder.ts new file mode 100644 index 00000000..9286a54b --- /dev/null +++ b/packages/purchasely/src/v6/startBuilder.ts @@ -0,0 +1,153 @@ +import { NativeModules } from 'react-native'; + +import { LogLevels, RunningMode } from '../enums'; + +type LogLevelString = 'debug' | 'info' | 'warn' | 'error'; +type RunningModeString = 'observer' | 'full'; +type AndroidStore = 'google' | 'huawei' | 'amazon'; +type StorekitVersion = 'storeKit1' | 'storeKit2'; + +const LOG_LEVEL_MAP: Record = { + debug: LogLevels.DEBUG, + info: LogLevels.INFO, + warn: LogLevels.WARNING, + error: LogLevels.ERROR, +}; + +const RUNNING_MODE_MAP: Record = { + observer: RunningMode.OBSERVER, + full: RunningMode.FULL, +}; + +interface StartBuilderState { + apiKey: string; + appUserId?: string | null; + runningMode: RunningModeString; + logLevel: LogLevelString; + allowDeeplink: boolean; + allowCampaigns: boolean; + androidStores: AndroidStore[]; + storekitVersion: StorekitVersion; +} + +/** + * Cross-platform builder for `Purchasely.start()` (v6). + * + * Mirrors the Android/iOS contract: + * - `allowDeeplink` / `allowCampaigns` are part of the chain (Android-style). + * On iOS the bridge expands them to the equivalent class funcs while the + * native chain catches up. + * - `stores(...)` is Android-only. + * - `storekitVersion(...)` is iOS-only. + * + * In v6 the default running mode is `'observer'` — the host app keeps full + * control of the purchase flow unless it opts into `'full'`. + */ +export class PurchaselyBuilder { + /** + * Version string forwarded to the native layer (`sdkBridgeVersion`). + * Populated by the package root before exposing the builder. + * + * @internal + */ + static bridgeVersion = '6.0.0'; + + private constructor(private readonly state: StartBuilderState) {} + + static apiKey(key: string): PurchaselyBuilder { + return new PurchaselyBuilder({ + apiKey: key, + runningMode: 'observer', + logLevel: 'error', + allowDeeplink: true, + allowCampaigns: true, + androidStores: ['google'], + storekitVersion: 'storeKit2', + }); + } + + appUserId(id: string | null): this { + this.state.appUserId = id; + return this; + } + + runningMode(mode: RunningModeString): this { + this.state.runningMode = mode; + return this; + } + + logLevel(level: LogLevelString): this { + this.state.logLevel = level; + return this; + } + + allowDeeplink(allow: boolean): this { + this.state.allowDeeplink = allow; + return this; + } + + allowCampaigns(allow: boolean): this { + this.state.allowCampaigns = allow; + return this; + } + + /** Android-only. */ + stores(stores: AndroidStore[]): this { + this.state.androidStores = stores; + return this; + } + + /** iOS-only. */ + storekitVersion(version: StorekitVersion): this { + this.state.storekitVersion = version; + return this; + } + + /** + * Finalize the builder and start the SDK. + * + * @param sdkVersion Optional override for the bridge version string. By + * default the version is injected by the wrapper exposed via + * `Purchasely.builder()`. + */ + async start(sdkVersion?: string): Promise { + const bridgeVersion = sdkVersion ?? PurchaselyBuilder.bridgeVersion; + const androidStoreNames = this.state.androidStores.map((s) => { + switch (s) { + case 'google': + return 'Google'; + case 'huawei': + return 'Huawei'; + case 'amazon': + return 'Amazon'; + default: + return s; + } + }); + + const configured: boolean = await NativeModules.Purchasely.start( + this.state.apiKey, + androidStoreNames, + this.state.storekitVersion === 'storeKit1', + this.state.appUserId ?? null, + LOG_LEVEL_MAP[this.state.logLevel], + RUNNING_MODE_MAP[this.state.runningMode], + bridgeVersion + ); + + // Apply the v6 chain-only options through the bridge. + if (NativeModules.Purchasely.v6ApplyStartOptions) { + NativeModules.Purchasely.v6ApplyStartOptions({ + allowDeeplink: this.state.allowDeeplink, + allowCampaigns: this.state.allowCampaigns, + }); + } else { + // Fallback for older native bridges still ignoring v6ApplyStartOptions. + NativeModules.Purchasely.readyToOpenDeeplink( + this.state.allowDeeplink + ); + } + + return configured; + } +} diff --git a/packages/purchasely/src/v6/types.ts b/packages/purchasely/src/v6/types.ts new file mode 100644 index 00000000..0e4bb5bb --- /dev/null +++ b/packages/purchasely/src/v6/types.ts @@ -0,0 +1,196 @@ +/** + * v6 cross-platform bridge contract types. + * See: reports/v6-presentation-comparison-v3-claude/BRIDGE-CONTRACT.md + * + * These types are exposed by the v6 builder API + * (`PresentationBuilder`, `Purchasely.interceptAction`, `Purchasely.builder()`). + * + * The legacy v5 types in `types.ts` remain for backward compatibility. + */ + +import { ProductResult } from '../enums'; +import type { + PLYPresentationType, + PLYWebCheckoutProvider, +} from '../enums'; +import type { + PurchaselyPlan, + PurchaselyOffer, + PurchaselySubscriptionOffer, + PLYPresentationPlan, + PLYPresentationMetadata, +} from '../types'; + +/** + * Reason a Presentation was dismissed. + * Always `null` on iOS until native exposes the value (see contract P0.2). + */ +export type CloseReason = 'button' | 'backSystem' | 'programmatic'; + +/** Outcome of `purchaseResult` in {@link PresentationOutcome}. */ +export type PurchaseResultKind = 'purchased' | 'cancelled' | 'restored'; + +/** Error returned by the v6 presentation lifecycle. */ +export interface PresentationError { + code?: string | number | null; + message: string; + domain?: string | null; +} + +/** + * Presentation transition mode. + * + * `inlinePaywall` is not supported by the legacy `PLYPresentationView` and is + * exposed only for cross-platform parity. + */ +export interface Transition { + type: + | 'fullScreen' + | 'push' + | 'modal' + | 'drawer' + | 'popin' + | 'inlinePaywall'; + heightPercentage?: number | null; + dismissible?: boolean | null; + backgroundColors?: { + light?: string | null; + dark?: string | null; + } | null; +} + +/** + * Outcome of a {@link Presentation} display, resolved when the presentation is + * dismissed (Android-style). Five fields, mutually exclusive between + * `error` and `closeReason`. + */ +export interface PresentationOutcome { + presentation?: Presentation | null; + purchaseResult?: PurchaseResultKind | null; + plan?: PurchaselyPlan | null; + closeReason?: CloseReason | null; + error?: PresentationError | null; +} + +/** + * Cross-platform Presentation. The public identifier is `screenId` + * (mapped from iOS `presentation.id`). `id` is kept as an alias for + * compatibility but is deprecated. + */ +export interface Presentation { + /** Stable identifier of the screen. Maps to `presentation.id` on iOS. */ + screenId: string; + /** @deprecated use {@link Presentation.screenId}. Kept for compat. */ + id?: string; + placementId?: string | null; + contentId?: string | null; + audienceId?: string | null; + abTestId?: string | null; + abTestVariantId?: string | null; + language?: string | null; + type?: PLYPresentationType | null; + plans?: PLYPresentationPlan[] | null; + metadata?: PLYPresentationMetadata | null; + height?: number | null; +} + +/** Information surfaced when an interceptor is triggered. */ +export interface InterceptorInfo { + contentId?: string | null; + presentation?: Presentation | null; +} + +/** Result of running a custom interceptor block. */ +export type InterceptResult = 'success' | 'failed' | 'notHandled'; + +/** Known action kinds the interceptor can subscribe to. */ +export type PresentationActionKind = + | 'close' + | 'closeAll' + | 'login' + | 'navigate' + | 'purchase' + | 'restore' + | 'openPresentation' + | 'openPlacement' + | 'promoCode' + | 'webCheckout'; + +/** Typed payload for the navigate action. */ +export interface NavigatePayload { + kind: 'navigate'; + url: string; + title?: string | null; +} + +/** Typed payload for the purchase action. */ +export interface PurchasePayload { + kind: 'purchase'; + plan: PurchaselyPlan; + subscriptionOffer?: PurchaselySubscriptionOffer | null; + offer?: PurchaselyOffer | null; +} + +/** Typed payload for close / closeAll actions. */ +export interface ClosePayload { + kind: 'close' | 'closeAll'; + closeReason: CloseReason; +} + +/** Typed payload for the openPresentation action. */ +export interface OpenPresentationPayload { + kind: 'openPresentation'; + presentationId: string; +} + +/** Typed payload for the openPlacement action. */ +export interface OpenPlacementPayload { + kind: 'openPlacement'; + placementId: string; +} + +/** Typed payload for the webCheckout action. */ +export interface WebCheckoutPayload { + kind: 'webCheckout'; + url: string; + clientReferenceId: string; + queryParameterKey: string; + webCheckoutProvider: PLYWebCheckoutProvider | string; +} + +/** Union of every known interceptor payload. */ +export type ActionPayload = + | NavigatePayload + | PurchasePayload + | ClosePayload + | OpenPresentationPayload + | OpenPlacementPayload + | WebCheckoutPayload; + +/** Handler signature for action interception. */ +export type InterceptorHandler = ( + info: InterceptorInfo, + payload: ActionPayload | null +) => Promise | InterceptResult; + +/** + * Internal helper — convert the legacy v5 `ProductResult` ordinal to the + * v6 string form for the {@link PresentationOutcome.purchaseResult}. + */ +export function purchaseResultFromOrdinal( + value: ProductResult | number | null | undefined +): PurchaseResultKind | null { + if (value === null || value === undefined) { + return null; + } + switch (value) { + case ProductResult.PRODUCT_RESULT_PURCHASED: + return 'purchased'; + case ProductResult.PRODUCT_RESULT_RESTORED: + return 'restored'; + case ProductResult.PRODUCT_RESULT_CANCELLED: + return 'cancelled'; + default: + return null; + } +} From 772cc990c84356c7901a8f56bfc86977d9b45107 Mon Sep 17 00:00:00 2001 From: Kevin Date: Thu, 28 May 2026 19:04:12 +0200 Subject: [PATCH 02/77] feat(android): wire React Native bridge to v6 SDK (builder, outcome, interceptor) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- packages/amazon/android/build.gradle | 2 +- packages/android-player/android/build.gradle | 2 +- packages/google/android/build.gradle | 2 +- packages/huawei/android/build.gradle | 2 +- packages/purchasely/android/build.gradle | 2 +- .../PLYProductActivity.kt | 170 +------ .../reactnativepurchasely/PurchaselyModule.kt | 330 ++++++------ .../PurchaselyViewManager.kt | 130 ++--- .../v6/PurchaselyV6Module.kt | 479 ++++++++++++++++++ 9 files changed, 719 insertions(+), 400 deletions(-) create mode 100644 packages/purchasely/android/src/main/java/com/reactnativepurchasely/v6/PurchaselyV6Module.kt diff --git a/packages/amazon/android/build.gradle b/packages/amazon/android/build.gradle index 1c427d46..63c18f8b 100644 --- a/packages/amazon/android/build.gradle +++ b/packages/amazon/android/build.gradle @@ -61,5 +61,5 @@ dependencies { api 'com.facebook.react:react-native:+' implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" - implementation 'io.purchasely:amazon:5.7.4' + implementation 'io.purchasely:amazon:6.0.0' } diff --git a/packages/android-player/android/build.gradle b/packages/android-player/android/build.gradle index 401589b3..ffeccbc8 100644 --- a/packages/android-player/android/build.gradle +++ b/packages/android-player/android/build.gradle @@ -62,5 +62,5 @@ dependencies { api 'com.facebook.react:react-native:+' implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" - implementation 'io.purchasely:player:5.7.4' + implementation 'io.purchasely:player:6.0.0' } diff --git a/packages/google/android/build.gradle b/packages/google/android/build.gradle index 881c367b..1135bdf0 100644 --- a/packages/google/android/build.gradle +++ b/packages/google/android/build.gradle @@ -62,5 +62,5 @@ dependencies { api 'com.facebook.react:react-native:+' implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" - implementation 'io.purchasely:google-play:5.7.4' + implementation 'io.purchasely:google-play:6.0.0' } diff --git a/packages/huawei/android/build.gradle b/packages/huawei/android/build.gradle index a0ba754b..5ab1850d 100644 --- a/packages/huawei/android/build.gradle +++ b/packages/huawei/android/build.gradle @@ -65,5 +65,5 @@ dependencies { api 'com.facebook.react:react-native:+' implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" - implementation 'io.purchasely:huawei-services:5.7.4' + implementation 'io.purchasely:huawei-services:6.0.0' } diff --git a/packages/purchasely/android/build.gradle b/packages/purchasely/android/build.gradle index f0b16504..127495b1 100644 --- a/packages/purchasely/android/build.gradle +++ b/packages/purchasely/android/build.gradle @@ -138,7 +138,7 @@ dependencies { implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.2' implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3' - api 'io.purchasely:core:5.7.4' + api 'io.purchasely:core:6.0.0' api 'androidx.lifecycle:lifecycle-common-java8:2.2.0' // Test dependencies diff --git a/packages/purchasely/android/src/main/java/com/reactnativepurchasely/PLYProductActivity.kt b/packages/purchasely/android/src/main/java/com/reactnativepurchasely/PLYProductActivity.kt index ad2dd413..a12ada14 100644 --- a/packages/purchasely/android/src/main/java/com/reactnativepurchasely/PLYProductActivity.kt +++ b/packages/purchasely/android/src/main/java/com/reactnativepurchasely/PLYProductActivity.kt @@ -2,163 +2,37 @@ package com.reactnativepurchasely import android.app.Activity import android.content.Intent -import android.graphics.Color import android.os.Bundle -import android.view.View -import android.widget.FrameLayout import androidx.appcompat.app.AppCompatActivity -import androidx.core.view.WindowCompat -import io.purchasely.ext.PLYPresentation -import io.purchasely.ext.PLYPresentationProperties -import io.purchasely.ext.PLYProductViewResult -import io.purchasely.ext.Purchasely -import io.purchasely.models.PLYPlan -import io.purchasely.views.parseColor -import java.lang.ref.WeakReference -import android.view.WindowManager +/** + * Stub activity kept solely to honour the AndroidManifest declaration produced + * by v5. The v6 React Native bridge no longer hosts paywalls in this Activity — + * presentations flow through `Purchasely.builder(...).display()` (which delegates + * to the SDK's own activity). + * + * The companion `newIntent(...)` helper is preserved so any v5 caller that still + * references it compiles but its return value points to this stub, which + * immediately finishes — surfacing the missing v6 migration in development. + */ class PLYProductActivity : AppCompatActivity() { - private var presentationId: String? = null - private var placementId: String? = null - private var productId: String? = null - private var planId: String? = null - private var contentId: String? = null - - private var presentation: PLYPresentation? = null - - private var isFullScreen: Boolean = false - private var backgroundColor: String? = null - - private var paywallView: View? = null - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - - isFullScreen = intent.extras?.getBoolean("isFullScreen") ?: false - backgroundColor = intent.extras?.getString("background_color") - - if(isFullScreen) { - WindowCompat.setDecorFitsSystemWindows(window, false) - window.setFlags(WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS, WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS); - } - - setContentView(R.layout.activity_ply_product_activity) - - try { - val loadingBackgroundColor = backgroundColor.parseColor(Color.WHITE) - findViewById(R.id.container).setBackgroundColor(loadingBackgroundColor) - } catch (e: Exception) { - //do nothing - } - - presentationId = intent.extras?.getString("presentationId") - placementId = intent.extras?.getString("placementId") - productId = intent.extras?.getString("productId") - planId = intent.extras?.getString("planId") - contentId = intent.extras?.getString("contentId") - - presentation = intent.extras?.getParcelable("presentation") - - paywallView = if(presentation != null) { - presentation?.buildView(this, properties = PLYPresentationProperties(onClose = { - findViewById(R.id.container).removeAllViews() - supportFinishAfterTransition() - }), callback) - } else { - Purchasely.presentationView( - context = this@PLYProductActivity, - properties = PLYPresentationProperties( - placementId = placementId, - contentId = contentId, - presentationId = presentationId, - planId = planId, - productId = productId, - onLoaded = { isLoaded -> - if(!isLoaded) return@PLYPresentationProperties - - val backgroundPaywall = paywallView?.findViewById(io.purchasely.R.id.content)?.background - if(backgroundPaywall != null) { - findViewById(R.id.container).background = backgroundPaywall - } - }, - onClose = { - findViewById(R.id.container).removeAllViews() - supportFinishAfterTransition() - } - ), - callback = callback - ) - } - - if(paywallView == null) { - finish() - return - } - - - findViewById(R.id.container).addView(paywallView) - } - - private fun hideSystemUI() { - actionBar?.hide() - window.decorView.systemUiVisibility = ( - View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY - or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION - or View.SYSTEM_UI_FLAG_FULLSCREEN - ) - } - - override fun onStart() { - super.onStart() - - PurchaselyModule.productActivity = PurchaselyModule.ProductActivity( - presentation = presentation, - presentationId = presentationId, - placementId = placementId, - productId = productId, - planId = planId, - contentId = contentId, - isFullScreen = isFullScreen, - loadingBackgroundColor = backgroundColor - ).apply { - activity = WeakReference(this@PLYProductActivity) - } - } - - override fun onDestroy() { - if(PurchaselyModule.productActivity?.activity?.get() == this) { - PurchaselyModule.productActivity?.activity = null - } - super.onDestroy() - } - - private val callback: (PLYProductViewResult, PLYPlan?) -> Unit = { result, plan -> - PurchaselyModule.sendPurchaseResult(result, plan) - //supportFinishAfterTransition() + finish() } companion object { - fun newIntent(activity: Activity?, - properties: PLYPresentationProperties, - isFullScreen: Boolean = false, - backgroundColor: String?) = Intent(activity, PLYProductActivity::class.java).apply { - //remove old activity if still referenced to avoid issues - val oldActivity = PurchaselyModule.productActivity?.activity?.get() - oldActivity?.finish() - PurchaselyModule.productActivity?.activity = null - PurchaselyModule.productActivity = null - //flags = Intent.FLAG_ACTIVITY_NEW_TASK xor Intent.FLAG_ACTIVITY_MULTIPLE_TASK - - putExtra("background_color", backgroundColor) - putExtra("isFullScreen", isFullScreen) - - putExtra("presentationId", properties.presentationId) - putExtra("contentId", properties.contentId) - putExtra("placementId", properties.placementId) - putExtra("productId", properties.productId) - putExtra("planId", properties.planId) - } + @JvmStatic + fun newIntent( + activity: Activity?, + @Suppress("UNUSED_PARAMETER") placementId: String? = null, + @Suppress("UNUSED_PARAMETER") presentationId: String? = null, + @Suppress("UNUSED_PARAMETER") productId: String? = null, + @Suppress("UNUSED_PARAMETER") planId: String? = null, + @Suppress("UNUSED_PARAMETER") contentId: String? = null, + @Suppress("UNUSED_PARAMETER") isFullScreen: Boolean = false, + @Suppress("UNUSED_PARAMETER") backgroundColor: String? = null + ): Intent = Intent(activity, PLYProductActivity::class.java) } - } diff --git a/packages/purchasely/android/src/main/java/com/reactnativepurchasely/PurchaselyModule.kt b/packages/purchasely/android/src/main/java/com/reactnativepurchasely/PurchaselyModule.kt index 54841a4e..1c9cbb49 100644 --- a/packages/purchasely/android/src/main/java/com/reactnativepurchasely/PurchaselyModule.kt +++ b/packages/purchasely/android/src/main/java/com/reactnativepurchasely/PurchaselyModule.kt @@ -11,15 +11,13 @@ import com.facebook.react.modules.core.DeviceEventManagerModule.RCTDeviceEventEm import io.purchasely.billing.Store import io.purchasely.ext.* import io.purchasely.ext.EventListener -import io.purchasely.models.PLYError +import io.purchasely.ext.presentation.PLYPresentation +import io.purchasely.ext.presentation.PLYPresentationType import io.purchasely.models.PLYPlan -import io.purchasely.models.PLYPromoOffer import io.purchasely.models.PLYPresentationPlan import io.purchasely.storage.userData.PLYUserAttributeSource import io.purchasely.storage.userData.PLYUserAttributeType import io.purchasely.views.presentation.PLYThemeMode -import io.purchasely.views.presentation.models.PLYTransition -import io.purchasely.views.presentation.models.PLYTransitionType import io.purchasely.ext.PLYDataProcessingLegalBasis import io.purchasely.ext.PLYDataProcessingPurpose import kotlinx.coroutines.* @@ -83,8 +81,11 @@ class PurchaselyModule internal constructor(context: ReactApplicationContext) : constants["logLevelWarn"] = LogLevel.WARN.ordinal constants["logLevelInfo"] = LogLevel.INFO.ordinal constants["logLevelError"] = LogLevel.ERROR.ordinal + @Suppress("DEPRECATION") constants["productResultPurchased"] = PLYProductViewResult.PURCHASED.ordinal + @Suppress("DEPRECATION") constants["productResultCancelled"] = PLYProductViewResult.CANCELLED.ordinal + @Suppress("DEPRECATION") constants["productResultRestored"] = PLYProductViewResult.RESTORED.ordinal constants["firebaseAppInstanceId"] = Attribute.FIREBASE_APP_INSTANCE_ID.ordinal constants["airshipChannelId"] = Attribute.AIRSHIP_CHANNEL_ID.ordinal @@ -282,8 +283,8 @@ class PurchaselyModule internal constructor(context: ReactApplicationContext) : @ReactMethod fun setDefaultPresentationResultHandler(promise: Promise) { defaultPurchasePromise = promise - Purchasely.setDefaultPresentationResultHandler { result, plan -> - sendPurchaseResult(result, plan) + Purchasely.setDefaultPresentationResultHandler { outcome -> + sendPurchaseResultV6(outcome) } } @@ -292,45 +293,21 @@ class PurchaselyModule internal constructor(context: ReactApplicationContext) : Purchasely.synchronize() } + /** + * @deprecated since v6.0.0 — use the v6 builder API + * (`PresentationBuilder.placement(id).build().preload()`). This method now + * rejects with a migration message; the JS layer keeps a thin wrapper that + * surfaces the same deprecation notice. + */ @ReactMethod fun fetchPresentation(placementId: String?, presentationId: String?, contentId: String?, promise: Promise) { - - - val properties = PLYPresentationProperties( - placementId = placementId, - presentationId = presentationId, - contentId = contentId) - - - Purchasely.fetchPresentation(properties = properties) { presentation: PLYPresentation?, error: PLYError? -> - GlobalScope.launch { - if(presentation != null) { - mutex.withLock { - presentationsLoaded.removeAll { it.id == presentation.id && it.placementId == presentation.placementId } - presentationsLoaded.add(presentation) - val map = presentation.toMap().mapValues { - val value = it.value - when(value) { - is PLYPresentationType -> value.ordinal - is PLYTransitionType -> value.ordinal - else -> value - } - } - - val mutableMap = map.toMutableMap().apply { - this["metadata"] = presentation.metadata?.toMap() - this["plans"] = (this["plans"] as List).map { it.toMap() } - this["height"] = presentation.height ?: 0 - } - promise.resolve(Arguments.makeNativeMap(mutableMap)) - } - } - if(error != null) promise.reject(IllegalStateException(error.message ?: "Unable to fetch presentation")) - } - } + promise.reject( + "v6_migration_required", + "fetchPresentation is removed in v6. Use Purchasely.presentation.placement(id).build().preload()." + ) } @ReactMethod @@ -338,34 +315,10 @@ class PurchaselyModule internal constructor(context: ReactApplicationContext) : isFullScreen: Boolean, loadingBackgroundColor: String?, promise: Promise) { - if (presentationMap == null) { - promise.reject(NullPointerException("presentation cannot be null")) - return - } - - val presentation = presentationsLoaded.lastOrNull { - it.id == presentationMap.getString("id") - && it.placementId == presentationMap.getString("placementId") - } - if(presentation == null) { - promise.reject(NullPointerException("presentation not fond")) - return - } - - purchasePromise = promise - - reactApplicationContext.currentActivity?.let { activity -> - if (presentation.flowId != null) { - presentation.display(activity) { result, plan -> - sendPurchaseResult(result, plan) - } - } else { - val intent = PLYProductActivity.newIntent(activity, PLYPresentationProperties(), isFullScreen, loadingBackgroundColor).apply { - putExtra("presentation", presentation) - } - activity.startActivity(intent) - } - } + promise.reject( + "v6_migration_required", + "presentPresentation is removed in v6. Use Purchasely.presentation.placement(id).build().display()." + ) } @ReactMethod @@ -374,15 +327,10 @@ class PurchaselyModule internal constructor(context: ReactApplicationContext) : isFullScreen: Boolean, loadingBackgroundColor: String?, promise: Promise) { - purchasePromise = promise - reactApplicationContext.currentActivity?.let { - val properties = PLYPresentationProperties( - presentationId = presentationVendorId, - contentId = contentId - ) - val intent = PLYProductActivity.newIntent(it, properties, isFullScreen, loadingBackgroundColor) - it.startActivity(intent) - } + promise.reject( + "v6_migration_required", + "presentPresentationWithIdentifier is removed in v6. Use Purchasely.presentation.screen(id).build().display()." + ) } @ReactMethod @@ -391,15 +339,10 @@ class PurchaselyModule internal constructor(context: ReactApplicationContext) : isFullScreen: Boolean, loadingBackgroundColor: String?, promise: Promise) { - purchasePromise = promise - reactApplicationContext.currentActivity?.let { - val properties = PLYPresentationProperties( - placementId = placementVendorId, - contentId = contentId - ) - val intent = PLYProductActivity.newIntent(it, properties, isFullScreen, loadingBackgroundColor) - it.startActivity(intent) - } + promise.reject( + "v6_migration_required", + "presentPresentationForPlacement is removed in v6. Use Purchasely.presentation.placement(id).build().display()." + ) } @ReactMethod @@ -409,16 +352,10 @@ class PurchaselyModule internal constructor(context: ReactApplicationContext) : isFullScreen: Boolean, loadingBackgroundColor: String?, promise: Promise) { - purchasePromise = promise - reactApplicationContext.currentActivity?.let { - val properties = PLYPresentationProperties( - presentationId = presentationVendorId, - productId = productVendorId, - contentId = contentId - ) - val intent = PLYProductActivity.newIntent(it, properties, isFullScreen, loadingBackgroundColor) - it.startActivity(intent) - } + promise.reject( + "v6_migration_required", + "presentProductWithIdentifier is removed in v6. Use Purchasely.presentation.screen(id).contentId(c).build().display()." + ) } @ReactMethod @@ -428,16 +365,10 @@ class PurchaselyModule internal constructor(context: ReactApplicationContext) : isFullScreen: Boolean, loadingBackgroundColor: String?, promise: Promise) { - purchasePromise = promise - reactApplicationContext.currentActivity?.let { - val properties = PLYPresentationProperties( - presentationId = presentationVendorId, - planId = planVendorId, - contentId = contentId - ) - val intent = PLYProductActivity.newIntent(it, properties, isFullScreen, loadingBackgroundColor) - it.startActivity(intent) - } + promise.reject( + "v6_migration_required", + "presentPlanWithIdentifier is removed in v6. Use Purchasely.presentation.screen(id).build().display()." + ) } @ReactMethod @@ -773,42 +704,17 @@ fun decrementUserAttribute(key: String, value: Double, legalBasis: String?) { promise.resolve(Purchasely.isDeeplinkHandled(uri)) } + /** + * @deprecated since v6.0.0 — use `Purchasely.interceptAction(kind, handler)` + * from the v6 façade. The new API provides typed payloads, per-kind + * subscriptions, and a coroutine-based completion. + */ @ReactMethod fun setPaywallActionInterceptor(promise: Promise) { - Purchasely.setPaywallActionsInterceptor { info, action, parameters, processAction -> - paywallActionHandler = processAction - paywallAction = action - - val parametersForReact = hashMapOf(); - parametersForReact["title"] = parameters.title - parametersForReact["url"] = parameters.url?.toString() - parametersForReact["plan"] = transformPlanToMap(parameters.plan) - parametersForReact["offer"] = mapOf( - "vendorId" to parameters.offer?.vendorId, - "storeOfferId" to parameters.offer?.storeOfferId - ) - parametersForReact["subscriptionOffer"] = parameters.subscriptionOffer?.toMap() - parametersForReact["presentation"] = parameters.presentation - parametersForReact["placement"] = parameters.placement - parametersForReact["closeReason"] = parameters.closeReason?.name - parametersForReact["clientReferenceId"] = parameters?.clientReferenceId - parametersForReact["queryParameterKey"] = parameters?.queryParameterKey - parametersForReact["webCheckoutProvider"] = parameters?.webCheckoutProvider?.name - - promise.resolve(Arguments.makeNativeMap( - mapOf( - Pair("info", mapOf( - Pair("contentId", info?.contentId), - Pair("presentationId", info?.presentationId), - Pair("placementId", info?.placementId), - Pair("abTestId", info?.abTestId), - Pair("abTestVariantId", info?.abTestVariantId) - )), - Pair("action", action.value), - Pair("parameters", parametersForReact.filterNot { it.value == null }) - ) - )) - } + promise.reject( + "v6_migration_required", + "setPaywallActionInterceptor is removed in v6. Use Purchasely.interceptAction(kind, handler)." + ) } @ReactMethod @@ -842,17 +748,16 @@ fun decrementUserAttribute(key: String, value: Double, legalBasis: String?) { } } + /** + * @deprecated since v6.0.0 — use the v6 interceptor handler return value + * (`InterceptResult.success | failed | notHandled`). + */ @ReactMethod fun onProcessAction(processAction: Boolean) { - CoroutineScope(Dispatchers.Default).launch { - delay(500) - val activityHandler = productActivity?.activity?.get() ?: reactApplicationContext.currentActivity - withContext(Dispatchers.Main) { - activityHandler?.runOnUiThread { - paywallActionHandler?.invoke(processAction) - } - } - } + Log.w( + "Purchasely", + "onProcessAction is removed in v6. Return InterceptResult from your interceptAction handler." + ) } @ReactMethod @@ -940,6 +845,62 @@ fun decrementUserAttribute(key: String, value: Double, legalBasis: String?) { Purchasely.debugMode = enabled } + // region v6 — cross-platform bridge methods + // See: reports/v6-presentation-comparison-v3-claude/BRIDGE-CONTRACT.md + + @ReactMethod + fun v6Preload(requestId: String, payload: ReadableMap?, promise: Promise) { + com.reactnativepurchasely.v6.PurchaselyV6Bridge.preload( + reactApplicationContext, requestId, payload, promise + ) + } + + @ReactMethod + fun v6Display( + requestId: String, + payload: ReadableMap?, + transition: ReadableMap?, + promise: Promise + ) { + com.reactnativepurchasely.v6.PurchaselyV6Bridge.display( + reactApplicationContext, requestId, payload, transition, promise + ) + } + + @ReactMethod + fun v6Close(requestId: String) { + com.reactnativepurchasely.v6.PurchaselyV6Bridge.close(requestId) + } + + @ReactMethod + fun v6Back(requestId: String) { + com.reactnativepurchasely.v6.PurchaselyV6Bridge.back(requestId) + } + + @ReactMethod + fun v6RegisterInterceptor(kind: String) { + com.reactnativepurchasely.v6.PurchaselyV6Bridge.registerInterceptor( + reactApplicationContext, kind + ) + } + + @ReactMethod + fun v6UnregisterInterceptor(kind: String) { + com.reactnativepurchasely.v6.PurchaselyV6Bridge.unregisterInterceptor(kind) + } + + @ReactMethod + fun v6CompleteInterceptor(callbackId: String, result: String) { + com.reactnativepurchasely.v6.PurchaselyV6Bridge.completeInterceptor(callbackId, result) + } + + @ReactMethod + fun v6ApplyStartOptions(options: ReadableMap) { + com.reactnativepurchasely.v6.PurchaselyV6Bridge.applyStartOptions(options) + } + + // endregion + private fun mapPurposesFromReadableArray(purposes: ReadableArray): Set { val result = mutableSetOf() @@ -977,20 +938,25 @@ fun decrementUserAttribute(key: String, value: Double, legalBasis: String?) { var productActivity: ProductActivity? = null var purchasePromise: Promise? = null var defaultPurchasePromise: Promise? = null - var paywallActionHandler: PLYCompletionHandler? = null - var paywallAction: PLYPresentationAction? = null - - fun sendPurchaseResult(result: PLYProductViewResult, plan: PLYPlan?) { - val productViewResult = when(result) { - PLYProductViewResult.PURCHASED -> PLYProductViewResult.PURCHASED.ordinal - PLYProductViewResult.CANCELLED -> PLYProductViewResult.CANCELLED.ordinal - PLYProductViewResult.RESTORED -> PLYProductViewResult.RESTORED.ordinal - } + /** + * Backwards-compatible projection of a v6 `PLYPresentationOutcome` into the + * legacy `{result, plan}` shape consumed by the v5 JS API. The new v6 façade + * exposes the full outcome (with `closeReason` / `error`) via the dedicated + * `PURCHASELY_V6_DISMISSED` event. + */ + fun sendPurchaseResultV6(outcome: io.purchasely.ext.presentation.PLYPresentationOutcome) { + val resultOrdinal = when (outcome.purchaseResult) { + io.purchasely.ext.presentation.PLYPurchaseResult.PURCHASED -> 0 + io.purchasely.ext.presentation.PLYPurchaseResult.RESTORED -> 2 + io.purchasely.ext.presentation.PLYPurchaseResult.CANCELLED -> 1 + null -> 1 + } val map: MutableMap = HashMap() - map["result"] = productViewResult - map["plan"] = transformPlanToMap(plan) - purchasePromise?.resolve(Arguments.makeNativeMap(map)) ?: defaultPurchasePromise?.resolve(Arguments.makeNativeMap(map)) + map["result"] = resultOrdinal + map["plan"] = transformPlanToMap(outcome.plan) + purchasePromise?.resolve(Arguments.makeNativeMap(map)) + ?: defaultPurchasePromise?.resolve(Arguments.makeNativeMap(map)) } fun transformPlanToMap(plan: PLYPlan?): Map { @@ -1009,6 +975,10 @@ fun decrementUserAttribute(key: String, value: Double, legalBasis: String?) { } } + /** + * Legacy host wrapper kept as a stub so JS-side cached state from v5 sessions + * continues to parse. The v6 builder pipeline does not use this class. + */ class ProductActivity( val presentation: PLYPresentation? = null, val presentationId: String? = null, @@ -1017,45 +987,35 @@ fun decrementUserAttribute(key: String, value: Double, legalBasis: String?) { val planId: String? = null, val contentId: String? = null, val isFullScreen: Boolean = false, - val loadingBackgroundColor: String? = null) { - + val loadingBackgroundColor: String? = null + ) { var activity: WeakReference? = null - - fun relaunch(reactApplicationContext: ReactApplicationContext) : Boolean { + fun relaunch(reactApplicationContext: ReactApplicationContext): Boolean { val backgroundActivity = activity?.get() - return if(backgroundActivity != null - && !backgroundActivity.isFinishing - && !backgroundActivity.isDestroyed) { + return if (backgroundActivity != null + && !backgroundActivity.isFinishing + && !backgroundActivity.isDestroyed + ) { reactApplicationContext.currentActivity?.let { it.startActivity( Intent(it, backgroundActivity::class.java).apply { - //flags = Intent.FLAG_ACTIVITY_NEW_TASK flags = Intent.FLAG_ACTIVITY_REORDER_TO_FRONT } ) } true } else { - reactApplicationContext.currentActivity?.let { - val properties = PLYPresentationProperties( - presentationId = presentationId, - placementId = placementId, - productId = productId, - planId = planId, - contentId = contentId - ) - val intent = PLYProductActivity.newIntent(it, properties, isFullScreen, loadingBackgroundColor).apply { - putExtra("presentation", presentation) - } - it.startActivity(intent) - } - return false + Log.w( + "Purchasely", + "[v6] Legacy productActivity has no live host. Use the v6 builder to (re)display." + ) + false } } } - fun PLYPresentationPlan.toMap() : Map { + fun PLYPresentationPlan.toMap(): Map { return mapOf( Pair("planVendorId", planVendorId), Pair("storeProductId", storeProductId), @@ -1064,7 +1024,7 @@ fun decrementUserAttribute(key: String, value: Double, legalBasis: String?) { ) } - suspend fun PLYPresentationMetadata.toMap() : Map { + suspend fun io.purchasely.ext.presentation.PLYPresentationMetadata.toMap(): Map { val metadata = mutableMapOf() this.keys()?.forEach { key -> val value = when (this.type(key)) { diff --git a/packages/purchasely/android/src/main/java/com/reactnativepurchasely/PurchaselyViewManager.kt b/packages/purchasely/android/src/main/java/com/reactnativepurchasely/PurchaselyViewManager.kt index e36bfced..ce3b8210 100644 --- a/packages/purchasely/android/src/main/java/com/reactnativepurchasely/PurchaselyViewManager.kt +++ b/packages/purchasely/android/src/main/java/com/reactnativepurchasely/PurchaselyViewManager.kt @@ -20,20 +20,32 @@ import com.facebook.react.uimanager.ThemedReactContext import com.facebook.react.uimanager.ViewGroupManager import com.facebook.react.uimanager.annotations.ReactProp import com.facebook.react.uimanager.annotations.ReactPropGroup -import io.purchasely.ext.PLYPresentation -import io.purchasely.ext.PLYPresentationResultHandler -import io.purchasely.ext.PLYPresentationProperties -import io.purchasely.ext.PLYProductViewResult -import io.purchasely.ext.Purchasely +import io.purchasely.ext.presentation.PLYPresentationBase +import io.purchasely.ext.presentation.PLYPresentationOutcome +import io.purchasely.ext.presentation.PLYPurchaseResult +import io.purchasely.ext.presentation.preload import io.purchasely.views.presentation.PLYPresentationView import android.content.res.Configuration - +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * View manager for ``. Hosts a v6 `PLYPresentationView` + * inside a React-Native managed Fragment. + * + * The presentation is sourced either from a `placementId` prop or from a + * `presentation` map produced by the v6 builder. Outcomes flow back to JS + * through the same `PURCHASELY_V6_DISMISSED`-friendly shape used by the v5 view + * (`{ result, plan }`), preserving the existing `onPresentationClosed` contract. + */ class PurchaselyViewManager(private val reactContext: ReactApplicationContext) : ViewGroupManager() { private var propWidth: Int? = null private var propHeight: Int? = null private var placementId: String? = null - private var presentation: PLYPresentation? = null + private var screenId: String? = null override fun getName(): String = "PurchaselyView" @@ -47,9 +59,8 @@ class PurchaselyViewManager(private val reactContext: ReactApplicationContext) : } } - override fun getCommandsMap(): Map? { - return MapBuilder.of("create", COMMAND_CREATE) - } + override fun getCommandsMap(): Map = + MapBuilder.of("create", COMMAND_CREATE) override fun receiveCommand(root: FrameLayout, commandId: Int, args: ReadableArray?) { Log.d("PurchaselyView", "Received a command having commandId=$commandId.") @@ -64,9 +75,7 @@ class PurchaselyViewManager(private val reactContext: ReactApplicationContext) : override fun receiveCommand(root: FrameLayout, commandId: String?, args: ReadableArray?) { super.receiveCommand(root, commandId, args) - Log.d("PurchaselyView", "Received a command having commandId=$commandId.") - super.receiveCommand(root, commandId, args) val reactNativeViewId = args?.getInt(0) ?: return val commandIdInt = commandId?.toIntOrNull() ?: return @@ -83,12 +92,10 @@ class PurchaselyViewManager(private val reactContext: ReactApplicationContext) : val parentView = root.findViewById(reactNativeViewId) ?: return setupLayout(parentView) - // Ensuring the container has the expected id if (parentView.id != reactNativeViewId) { parentView.id = reactNativeViewId } - // Only transact when the container is attached to window if (!parentView.isAttachedToWindow) { parentView.addOnAttachStateChangeListener(object : View.OnAttachStateChangeListener { override fun onViewAttachedToWindow(v: View) { @@ -104,26 +111,26 @@ class PurchaselyViewManager(private val reactContext: ReactApplicationContext) : val fm = activity.supportFragmentManager val existing = fm.findFragmentByTag(tag) if (existing != null && existing.isAdded) { - // Already attached to this container. nothing to do. return } - val fragment = PurchaselyFragment(presentation, placementId) { result, plan -> - val productViewResult = when(result) { - PLYProductViewResult.PURCHASED -> PLYProductViewResult.PURCHASED.ordinal - PLYProductViewResult.CANCELLED -> PLYProductViewResult.CANCELLED.ordinal - PLYProductViewResult.RESTORED -> PLYProductViewResult.RESTORED.ordinal + val outcomeHandler: (PLYPresentationOutcome) -> Unit = { outcome -> + val resultOrdinal = when (outcome.purchaseResult) { + PLYPurchaseResult.PURCHASED -> 0 + PLYPurchaseResult.RESTORED -> 2 + PLYPurchaseResult.CANCELLED -> 1 + null -> 1 } - val map: MutableMap = HashMap() - map["result"] = productViewResult - map["plan"] = PurchaselyModule.transformPlanToMap(plan) + map["result"] = resultOrdinal + map["plan"] = PurchaselyModule.transformPlanToMap(outcome.plan) (promiseView ?: PurchaselyModule.defaultPurchasePromise) ?.resolve(Arguments.makeNativeMap(map)) promiseView = null } - // Safer transaction flags + val fragment = PurchaselyFragment(screenId, placementId, outcomeHandler) + fm.beginTransaction() .setReorderingAllowed(true) .replace(reactNativeViewId, fragment, tag) @@ -140,11 +147,7 @@ class PurchaselyViewManager(private val reactContext: ReactApplicationContext) : }) } - /** - * Layout all children properly - */ fun manuallyLayoutChildren(view: View) { - // propWidth and propHeight coming from react-native props for (i in 0 until (view as ViewGroup).childCount) { val child = view.getChildAt(i) val width: Int = propWidth ?: when { @@ -159,7 +162,8 @@ class PurchaselyViewManager(private val reactContext: ReactApplicationContext) : } child.measure( View.MeasureSpec.makeMeasureSpec(width, View.MeasureSpec.EXACTLY), - View.MeasureSpec.makeMeasureSpec(height, View.MeasureSpec.EXACTLY)) + View.MeasureSpec.makeMeasureSpec(height, View.MeasureSpec.EXACTLY) + ) child.layout(0, 0, width, height) } } @@ -177,10 +181,9 @@ class PurchaselyViewManager(private val reactContext: ReactApplicationContext) : @ReactProp(name = "presentation") fun setPresentation(view: FrameLayout?, value: ReadableMap?) { - presentation = PurchaselyModule.presentationsLoaded.lastOrNull { - it.id == value?.getString("id") - && it.placementId == value?.getString("placementId") - } + // The JS layer forwards either `id` (legacy) or `screenId` (v6). + screenId = value?.getString("screenId") ?: value?.getString("id") + placementId = placementId ?: value?.getString("placementId") } @ReactMethod @@ -207,50 +210,53 @@ class PurchaselyViewManager(private val reactContext: ReactApplicationContext) : private var promiseView: Promise? = null } - /** - * Purchasely Fragment to host the PLYPresentationView + * Fragment hosting a v6 `PLYPresentationView`. The presentation is built + * lazily inside `onViewCreated` so the SDK can attach to the live Activity. */ class PurchaselyFragment( - private val presentation: PLYPresentation?, + private val screenId: String?, private val placementId: String?, - private val callback: PLYPresentationResultHandler) : Fragment() { - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { - return FrameLayout(inflater.context) - } - - private fun closeCallback() { - (view as ViewGroup).removeAllViews() - parentFragmentManager - .beginTransaction() - .remove(this) - .commitAllowingStateLoss() - } - - - private fun buildPurchaselyView(view: View): PLYPresentationView? { - val props = PLYPresentationProperties( - placementId = placementId, - onClose = { closeCallback() } - ) - return if (presentation != null) { - presentation.buildView(view.context, properties = props, callback = callback) - } else { - Purchasely.presentationView(view.context, properties = props, callback = callback) + private val callback: (PLYPresentationOutcome) -> Unit + ) : Fragment() { + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View = FrameLayout(inflater.context) + + private fun attachPurchaselyView(host: ViewGroup) { + val prepared: PLYPresentationBase.Prepared = PLYPresentationBase.builder() + .also { b -> + placementId?.let { b.placementId(it) } + screenId?.let { b.screenId(it) } + } + .onDismissed { outcome -> callback(outcome) } + .build() + + CoroutineScope(Dispatchers.Main).launch { + try { + val loaded = withContext(Dispatchers.Default) { prepared.preload() } + val pv: PLYPresentationView? = + loaded.buildView(host.context) { outcome -> callback(outcome) } + pv?.let { host.addView(it) } + } catch (e: Throwable) { + Log.w("PurchaselyView", "Unable to build presentation view: ${e.message}", e) + } } } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - (view as ViewGroup).addView(buildPurchaselyView(view)) + attachPurchaselyView(view as ViewGroup) } override fun onConfigurationChanged(newConfig: Configuration) { super.onConfigurationChanged(newConfig) val host = view as? ViewGroup ?: return host.removeAllViews() - buildPurchaselyView(host)?.let { host.addView(it) } + attachPurchaselyView(host) } } } diff --git a/packages/purchasely/android/src/main/java/com/reactnativepurchasely/v6/PurchaselyV6Module.kt b/packages/purchasely/android/src/main/java/com/reactnativepurchasely/v6/PurchaselyV6Module.kt new file mode 100644 index 00000000..006fbfb0 --- /dev/null +++ b/packages/purchasely/android/src/main/java/com/reactnativepurchasely/v6/PurchaselyV6Module.kt @@ -0,0 +1,479 @@ +package com.reactnativepurchasely.v6 + +import android.app.Activity +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReactContext +import com.facebook.react.bridge.ReactMethod +import com.facebook.react.bridge.ReadableMap +import com.facebook.react.bridge.WritableMap +import com.facebook.react.modules.core.DeviceEventManagerModule.RCTDeviceEventEmitter +import com.reactnativepurchasely.PurchaselyModule +import io.purchasely.ext.PLYInterceptorInfo +import io.purchasely.ext.PLYInterceptResult +import io.purchasely.ext.PLYLogger +import io.purchasely.ext.Purchasely +import io.purchasely.ext.presentation.PLYCloseReason +import io.purchasely.ext.presentation.PLYPresentation +import io.purchasely.ext.presentation.PLYPresentationAction +import io.purchasely.ext.presentation.PLYPresentationBase +import io.purchasely.ext.presentation.PLYPresentationOutcome +import io.purchasely.ext.presentation.PLYPurchaseResult +import io.purchasely.ext.presentation.display +import io.purchasely.ext.presentation.preload +import io.purchasely.models.PLYError +import io.purchasely.views.presentation.models.PLYTransition +import io.purchasely.views.presentation.models.PLYTransitionType +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap + +/** + * Bridge implementation for the v6 cross-platform contract. + * + * The methods exposed here are called from the JS side as + * `NativeModules.Purchasely.v6Preload(...)`, etc. Lifecycle events are emitted + * over the existing `RCTDeviceEventEmitter` using the + * `PURCHASELY_V6_*` event names. + * + * Implemented as a static helper rather than a separate `ReactContextBaseJavaModule` + * so it can sit on the same `Purchasely` native module name as the legacy bridge. + * + * Contract: reports/v6-presentation-comparison-v3-claude/BRIDGE-CONTRACT.md + */ +object PurchaselyV6Bridge { + + private const val EVENT_LOADED = "PURCHASELY_V6_LOADED" + private const val EVENT_PRESENTED = "PURCHASELY_V6_PRESENTED" + private const val EVENT_CLOSE_REQUESTED = "PURCHASELY_V6_CLOSE_REQUESTED" + private const val EVENT_DISMISSED = "PURCHASELY_V6_DISMISSED" + private const val EVENT_ACTION_INTERCEPTED = "PURCHASELY_V6_ACTION_INTERCEPTED" + + /** + * Active presentation requests, keyed by the JS-supplied requestId. Lets + * `v6Close` / `v6Back` find the right `Prepared` to act on. + */ + private val activeRequests = ConcurrentHashMap() + + /** + * Pending interceptor callbacks. The bridge resolves the suspending block + * once JS calls back via [completeInterceptor]. + */ + private val pendingInterceptors = + ConcurrentHashMap>() + + /** + * Build a [PLYPresentationBase.Prepared] from the JS payload. + */ + private fun buildPrepared(payload: ReadableMap?): PLYPresentationBase.Prepared { + val builder = PLYPresentationBase.builder() + payload?.let { p -> + if (p.hasKey("placementId") && !p.isNull("placementId")) { + builder.placementId(p.getString("placementId")!!) + } + if (p.hasKey("presentationId") && !p.isNull("presentationId")) { + // The JS `screenId` is forwarded as the legacy native key `presentationId`. + builder.screenId(p.getString("presentationId")!!) + } + if (p.hasKey("contentId") && !p.isNull("contentId")) { + builder.contentId(p.getString("contentId")) + } + if (p.hasKey("displayCloseButton") && !p.isNull("displayCloseButton")) { + builder.displayCloseButton(p.getBoolean("displayCloseButton")) + } + if (p.hasKey("displayBackButton") && !p.isNull("displayBackButton")) { + builder.displayBackButton(p.getBoolean("displayBackButton")) + } + if (p.hasKey("backgroundColor") && !p.isNull("backgroundColor")) { + runCatching { + val color = android.graphics.Color.parseColor(p.getString("backgroundColor")) + builder.backgroundColor(color) + }.onFailure { + PLYLogger.w("[v6] invalid backgroundColor: ${p.getString("backgroundColor")}") + } + } + if (p.hasKey("progressColor") && !p.isNull("progressColor")) { + runCatching { + val color = android.graphics.Color.parseColor(p.getString("progressColor")) + builder.progressColor(color) + }.onFailure { + PLYLogger.w("[v6] invalid progressColor: ${p.getString("progressColor")}") + } + } + } + return builder.build() + } + + /** + * Convert a [PLYPresentation] to a React-Native map. We expose the screenId + * (mapped from the SDK `screenId`) and keep `id` as alias for compat. + */ + private fun PLYPresentation.toV6Map(): WritableMap { + val map = Arguments.createMap() + map.putString("screenId", screenId) + map.putString("id", screenId) + placementId?.let { map.putString("placementId", it) } + contentId?.let { map.putString("contentId", it) } + // Audience / AB-test ids live in the request payload; expose what we have. + runCatching { audienceId?.let { map.putString("audienceId", it) } } + runCatching { abTestId?.let { map.putString("abTestId", it) } } + runCatching { abTestVariantId?.let { map.putString("abTestVariantId", it) } } + runCatching { language?.let { map.putString("language", it) } } + runCatching { map.putInt("type", type.ordinal) } + runCatching { height?.let { map.putInt("height", it) } } + return map + } + + private fun PLYError.toV6Map(): WritableMap { + val map = Arguments.createMap() + map.putString("message", message ?: "Unknown error") + return map + } + + private fun PLYCloseReason.toV6String(): String = when (this) { + PLYCloseReason.BUTTON -> "button" + PLYCloseReason.BACK_SYSTEM -> "backSystem" + PLYCloseReason.PROGRAMMATIC -> "programmatic" + } + + private fun PLYPurchaseResult.toOrdinal(): Int = when (this) { + PLYPurchaseResult.PURCHASED -> 0 + PLYPurchaseResult.CANCELLED -> 1 + PLYPurchaseResult.RESTORED -> 2 + } + + private fun sendEvent( + context: ReactContext?, + eventName: String, + params: WritableMap? + ) { + context + ?.getJSModule(RCTDeviceEventEmitter::class.java) + ?.emit(eventName, params) + } + + private fun wireCallbacks( + context: ReactContext?, + requestId: String, + prepared: PLYPresentationBase.Prepared + ) { + prepared.onPresented = { presentation, error -> + val payload = Arguments.createMap() + payload.putString("requestId", requestId) + presentation?.let { payload.putMap("presentation", it.toV6Map()) } + error?.let { payload.putMap("error", it.toV6Map()) } + sendEvent(context, EVENT_PRESENTED, payload) + } + prepared.onCloseRequested = { + val payload = Arguments.createMap() + payload.putString("requestId", requestId) + sendEvent(context, EVENT_CLOSE_REQUESTED, payload) + } + prepared.onDismissed = { outcome: PLYPresentationOutcome -> + val payload = Arguments.createMap() + payload.putString("requestId", requestId) + outcome.presentation?.let { payload.putMap("presentation", it.toV6Map()) } + outcome.purchaseResult?.let { payload.putInt("purchaseResult", it.toOrdinal()) } + outcome.plan?.let { + payload.putMap("plan", Arguments.makeNativeMap( + PurchaselyModule.transformPlanToMap(it).toMutableMap() + )) + } + outcome.closeReason?.let { payload.putString("closeReason", it.toV6String()) } + outcome.error?.let { payload.putMap("error", it.toV6Map()) } + sendEvent(context, EVENT_DISMISSED, payload) + activeRequests.remove(requestId) + } + } + + /** + * v6 preload entry point. JS calls this with a requestId + builder payload. + */ + @JvmStatic + fun preload( + reactContext: ReactApplicationContext, + requestId: String, + payload: ReadableMap?, + promise: Promise + ) { + try { + val prepared = buildPrepared(payload) + activeRequests[requestId] = prepared + wireCallbacks(reactContext, requestId, prepared) + + prepared.preload { loaded, error -> + val map = Arguments.createMap() + map.putString("requestId", requestId) + loaded?.let { map.putMap("presentation", it.toV6Map()) } + error?.let { map.putMap("error", it.toV6Map()) } + sendEvent(reactContext, EVENT_LOADED, map) + } + promise.resolve(true) + } catch (e: Throwable) { + promise.reject("v6_preload_failure", e.message, e) + } + } + + /** + * v6 display entry point. JS calls this with a requestId + builder payload + * (+ optional transition). + */ + @JvmStatic + fun display( + reactContext: ReactApplicationContext, + requestId: String, + payload: ReadableMap?, + transitionMap: ReadableMap?, + promise: Promise + ) { + try { + val activity: Activity = reactContext.currentActivity + ?: throw IllegalStateException("No current activity to host the presentation") + + val prepared = activeRequests[requestId] ?: buildPrepared(payload).also { + activeRequests[requestId] = it + wireCallbacks(reactContext, requestId, it) + } + + val transition: PLYTransition? = transitionMap?.let { tm -> + if (!tm.hasKey("type") || tm.isNull("type")) { + null + } else { + val type = when (tm.getString("type")) { + "fullScreen" -> PLYTransitionType.FULLSCREEN + "push" -> PLYTransitionType.PUSH + "modal" -> PLYTransitionType.MODAL + "drawer" -> PLYTransitionType.DRAWER + "popin" -> PLYTransitionType.POPIN + // `inlinePaywall` not supported by PLYTransition — fall through. + else -> PLYTransitionType.FULLSCREEN + } + val heightPercentage = + if (tm.hasKey("heightPercentage") && !tm.isNull("heightPercentage")) { + tm.getDouble("heightPercentage").toFloat() + } else { + null + } + val dismissible = + if (tm.hasKey("dismissible") && !tm.isNull("dismissible")) { + tm.getBoolean("dismissible") + } else { + true + } + PLYTransition( + type = type, + heightPercentage = heightPercentage, + dismissible = dismissible + ) + } + } + + // The SDK DSL extension exposes the callback-form of display(). The + // outcome itself is already emitted to JS through `onDismissed` (wired in + // `wireCallbacks`), so the local `callback` is a noop. We still pass an + // outcome handler to ensure the SDK does not log a warning about a missing + // callback. + prepared.display( + context = activity, + transition = transition, + presentation = null, + callback = { /* dismissed event is sent via onDismissed */ } + ) + + promise.resolve(true) + } catch (e: Throwable) { + // Synthesize a dismissed event so the JS side resolves the display Promise. + val payload = Arguments.createMap() + payload.putString("requestId", requestId) + payload.putMap("error", Arguments.createMap().apply { + putString("message", e.message ?: "Display failed") + }) + sendEvent(reactContext, EVENT_DISMISSED, payload) + activeRequests.remove(requestId) + promise.reject("v6_display_failure", e.message, e) + } + } + + @JvmStatic + fun close(requestId: String) { + activeRequests.remove(requestId) + // Closing all screens is the closest match — the SDK v6 does not yet + // expose a per-request close. + Purchasely.closeAllScreens() + } + + @JvmStatic + fun back(requestId: String) { + // No public `back()` on the Java façade — surface as a noop log. + PLYLogger.w("[v6] back($requestId) is not yet bridged on Android") + } + + /** Register an interceptor for a given action kind. */ + @JvmStatic + fun registerInterceptor(reactContext: ReactApplicationContext, kind: String) { + val actionType: Class = when (kind) { + "close" -> PLYPresentationAction.Close::class.java + "closeAll" -> PLYPresentationAction.CloseAll::class.java + "login" -> PLYPresentationAction.Login::class.java + "navigate" -> PLYPresentationAction.Navigate::class.java + "purchase" -> PLYPresentationAction.Purchase::class.java + "restore" -> PLYPresentationAction.Restore::class.java + "openPresentation" -> PLYPresentationAction.OpenPresentation::class.java + "openPlacement" -> PLYPresentationAction.OpenPlacement::class.java + "promoCode" -> PLYPresentationAction.PromoCode::class.java + "webCheckout" -> PLYPresentationAction.WebCheckout::class.java + else -> { + PLYLogger.w("[v6] unknown interceptor kind: $kind") + return + } + } + + Purchasely.interceptAction(actionType) { info, action, complete -> + val callbackId = UUID.randomUUID().toString() + val deferred = CompletableDeferred() + pendingInterceptors[callbackId] = deferred + + val payload = Arguments.createMap() + payload.putString("requestId", "") + payload.putString("callbackId", callbackId) + payload.putString("kind", kind) + payload.putMap("info", info.toV6Map()) + payload.putMap("payload", action.toV6Payload()) + sendEvent(reactContext, EVENT_ACTION_INTERCEPTED, payload) + + CoroutineScope(Dispatchers.Main).launch { + val result = runCatching { deferred.await() } + .getOrDefault(PLYInterceptResult.NOT_HANDLED) + complete(result) + } + } + } + + @JvmStatic + fun unregisterInterceptor(kind: String) { + val actionType: Class? = when (kind) { + "close" -> PLYPresentationAction.Close::class.java + "closeAll" -> PLYPresentationAction.CloseAll::class.java + "login" -> PLYPresentationAction.Login::class.java + "navigate" -> PLYPresentationAction.Navigate::class.java + "purchase" -> PLYPresentationAction.Purchase::class.java + "restore" -> PLYPresentationAction.Restore::class.java + "openPresentation" -> PLYPresentationAction.OpenPresentation::class.java + "openPlacement" -> PLYPresentationAction.OpenPlacement::class.java + "promoCode" -> PLYPresentationAction.PromoCode::class.java + "webCheckout" -> PLYPresentationAction.WebCheckout::class.java + else -> null + } + if (actionType == null) { + PLYLogger.w("[v6] unknown interceptor kind: $kind") + return + } + runCatching { + Purchasely.removeActionInterceptor(actionType) + }.onFailure { + PLYLogger.w("[v6] removeActionInterceptor($kind) failed: ${it.message}") + } + } + + @JvmStatic + fun completeInterceptor(callbackId: String, result: String) { + val deferred = pendingInterceptors.remove(callbackId) ?: return + deferred.complete( + when (result) { + "success" -> PLYInterceptResult.SUCCESS + "failed" -> PLYInterceptResult.FAILED + else -> PLYInterceptResult.NOT_HANDLED + } + ) + } + + @JvmStatic + fun applyStartOptions(options: ReadableMap) { + if (options.hasKey("allowDeeplink") && !options.isNull("allowDeeplink")) { + Purchasely.readyToOpenDeeplink = options.getBoolean("allowDeeplink") + } + if (options.hasKey("allowCampaigns") && !options.isNull("allowCampaigns")) { + // No direct setter for "disallowCampaigns" — leverage the privacy API. + // If the host opts out, we record it through the consent manager. + if (!options.getBoolean("allowCampaigns")) { + runCatching { + Purchasely.revokeDataProcessingConsent( + setOf( + io.purchasely.ext.PLYDataProcessingPurpose.Campaigns + ) + ) + }.onFailure { + PLYLogger.w("[v6] allowCampaigns(false) could not be honored: ${it.message}") + } + } + } + } + + private fun PLYInterceptorInfo.toV6Map(): WritableMap { + val map = Arguments.createMap() + contentId?.let { map.putString("contentId", it) } + presentation?.let { map.putMap("presentation", it.toV6Map()) } + return map + } + + private fun PLYPresentationAction.toV6Payload(): WritableMap { + val payload = Arguments.createMap() + when (this) { + is PLYPresentationAction.Navigate -> { + payload.putString("url", url.toString()) + title?.let { payload.putString("title", it) } + } + is PLYPresentationAction.Purchase -> { + payload.putMap( + "plan", + Arguments.makeNativeMap( + PurchaselyModule.transformPlanToMap(plan).toMutableMap() + ) + ) + offer?.let { + val offerMap = Arguments.createMap() + it.vendorId?.let { v -> offerMap.putString("vendorId", v) } + it.storeOfferId?.let { v -> offerMap.putString("storeOfferId", v) } + payload.putMap("offer", offerMap) + } + subscriptionOffer?.let { so -> + val soMap = Arguments.createMap() + soMap.putString("subscriptionId", so.subscriptionId) + so.basePlanId?.let { soMap.putString("basePlanId", it) } + so.offerToken?.let { soMap.putString("offerToken", it) } + so.offerId?.let { soMap.putString("offerId", it) } + payload.putMap("subscriptionOffer", soMap) + } + } + is PLYPresentationAction.Close -> { + payload.putString("closeReason", closeReason.toV6String()) + } + is PLYPresentationAction.CloseAll -> { + payload.putString("closeReason", closeReason.toV6String()) + } + is PLYPresentationAction.OpenPresentation -> { + payload.putString("presentationId", presentationId) + } + is PLYPresentationAction.OpenPlacement -> { + payload.putString("placementId", placementId) + } + is PLYPresentationAction.WebCheckout -> { + payload.putString("url", url.toString()) + payload.putString("clientReferenceId", clientReferenceId) + payload.putString("queryParameterKey", queryParameterKey) + payload.putString( + "webCheckoutProvider", + webCheckoutProvider.name.lowercase() + ) + } + else -> { + // login, restore, promoCode → no extra payload. + } + } + return payload + } +} From 8d0d823675854fb4dfd50e8cee007d2ff705286b Mon Sep 17 00:00:00 2001 From: Kevin Date: Thu, 28 May 2026 21:47:00 +0200 Subject: [PATCH 03/77] chore(ios): scaffold v6 bridge category header + ignore caches 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) --- .gitignore | 4 +++- packages/purchasely/ios/PurchaselyRNV6.h | 24 ++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 packages/purchasely/ios/PurchaselyRNV6.h diff --git a/.gitignore b/.gitignore index b0e5923c..caac6189 100644 --- a/.gitignore +++ b/.gitignore @@ -88,4 +88,6 @@ nitrogen/ .nx/cache -.nx/workspace-data \ No newline at end of file +.nx/workspace-datajest_dx/ +node-compile-cache/ +**/coverage/ diff --git a/packages/purchasely/ios/PurchaselyRNV6.h b/packages/purchasely/ios/PurchaselyRNV6.h new file mode 100644 index 00000000..6d26bb4f --- /dev/null +++ b/packages/purchasely/ios/PurchaselyRNV6.h @@ -0,0 +1,24 @@ +// +// PurchaselyRNV6.h +// Purchasely-ReactNative +// +// Created on 2026-05-28. +// + +#import +#import + +#import "PurchaselyRN.h" + +NS_ASSUME_NONNULL_BEGIN + +/// v6 bridge category — adds the cross-platform contract methods on top of +/// the existing `Purchasely` native module. The legacy methods stay on the +/// main `PurchaselyRN.m` so backwards-compatible JS code keeps working. +/// +/// Contract: reports/v6-presentation-comparison-v3-claude/BRIDGE-CONTRACT.md +@interface PurchaselyRN (V6) + +@end + +NS_ASSUME_NONNULL_END From 8e2c918b22311c3407a2efd03a62b9c4c267950b Mon Sep 17 00:00:00 2001 From: Kevin Date: Thu, 28 May 2026 21:51:49 +0200 Subject: [PATCH 04/77] feat(ios): implement v6 bridge category on PurchaselyRN MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- packages/purchasely/ios/PurchaselyRNV6.m | 605 +++++++++++++++++++++++ 1 file changed, 605 insertions(+) create mode 100644 packages/purchasely/ios/PurchaselyRNV6.m diff --git a/packages/purchasely/ios/PurchaselyRNV6.m b/packages/purchasely/ios/PurchaselyRNV6.m new file mode 100644 index 00000000..d88cff36 --- /dev/null +++ b/packages/purchasely/ios/PurchaselyRNV6.m @@ -0,0 +1,605 @@ +// +// PurchaselyRNV6.m +// Purchasely-ReactNative +// +// v6 cross-platform bridge implementation. +// Implements the contract documented in: +// reports/v6-presentation-comparison-v3-claude/BRIDGE-CONTRACT.md +// +// Mapping notes (iOS-specific workarounds — see contract P0.2 / P0.4 / P1.1): +// - The native iOS SDK currently surfaces a `PLYProductViewControllerResult` + +// `PLYPlan` via the legacy `fetchPresentationFor:contentId:fetchCompletion:` +// callbacks. The v6 contract requires a 5-field outcome (presentation, +// purchaseResult, plan, closeReason, error). The bridge synthesizes the +// missing fields: +// * `presentation` is captured from the loaded `PLYPresentation`. +// * `closeReason` is set to `nil` (iOS does not yet expose it). +// * `error` is propagated from the fetch completion handler. +// - `screenId` maps to `presentation.id` until iOS exposes a dedicated +// `screenId` property. +// - `onPresented(presentation?, error?)` is synthesized after preload/display. +// - The Promise returned by `display()` resolves at DISMISS (not at trigger), +// matching the Android contract. +// + +#import +#import +#import +#import + +#import "PurchaselyRN.h" +#import "PurchaselyRNV6.h" + +#pragma mark - Event names + +static NSString *const kV6EventLoaded = @"PURCHASELY_V6_LOADED"; +static NSString *const kV6EventPresented = @"PURCHASELY_V6_PRESENTED"; +static NSString *const kV6EventCloseRequested = @"PURCHASELY_V6_CLOSE_REQUESTED"; +static NSString *const kV6EventDismissed = @"PURCHASELY_V6_DISMISSED"; +static NSString *const kV6EventActionIntercepted = @"PURCHASELY_V6_ACTION_INTERCEPTED"; + +#pragma mark - Internal state (shared across category methods) + +/// requestId → captured PLYPresentation (so we can replay it in events). +static NSMutableDictionary *kV6PresentationsByRequest; +/// callbackId → completion block to call once JS replies with an InterceptResult. +static NSMutableDictionary *kV6InterceptorCallbacks; +/// kind → BOOL : tracks which interceptor kinds JS has registered. The native +/// interceptor itself is global (`setPaywallActionsInterceptor`) — we only fire +/// the JS event when the action kind matches a registered one. +static NSMutableSet *kV6InterceptorKinds; + +static void V6EnsureInternalState(void) { + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + kV6PresentationsByRequest = [NSMutableDictionary new]; + kV6InterceptorCallbacks = [NSMutableDictionary new]; + kV6InterceptorKinds = [NSMutableSet new]; + }); +} + +#pragma mark - Helpers + +/// Map a `PLYPresentationAction` to its v6 string kind. +/// Mirrors the kind names emitted by the Android bridge. +static NSString *V6StringFromAction(PLYPresentationAction action) { + switch (action) { + case PLYPresentationActionLogin: return @"login"; + case PLYPresentationActionPurchase: return @"purchase"; + case PLYPresentationActionClose: return @"close"; + case PLYPresentationActionCloseAll: return @"closeAll"; + case PLYPresentationActionRestore: return @"restore"; + case PLYPresentationActionNavigate: return @"navigate"; + case PLYPresentationActionPromoCode: return @"promoCode"; + case PLYPresentationActionOpenPresentation:return @"openPresentation"; + case PLYPresentationActionOpenPlacement: return @"openPlacement"; + case PLYPresentationActionWebCheckout: return @"webCheckout"; + } + return @"unknown"; +} + +/// String representation of `PLYWebCheckoutProvider` for the JS payload. +static NSString *V6StringFromWebCheckoutProvider(PLYWebCheckoutProvider provider) { + switch (provider) { + case PLYWebCheckoutProviderStripe: return @"stripe"; + case PLYWebCheckoutProviderPaddle: return @"paddle"; + case PLYWebCheckoutProviderRecurly: return @"recurly"; + case PLYWebCheckoutProviderChargebee: return @"chargebee"; + case PLYWebCheckoutProviderPaypal: return @"paypal"; + case PLYWebCheckoutProviderRevenuecat: return @"revenuecat"; + case PLYWebCheckoutProviderAdapty: return @"adapty"; + case PLYWebCheckoutProviderQonversion: return @"qonversion"; + case PLYWebCheckoutProviderOther: return @"other"; + default: return @"unknown"; + } +} + +/// Convert a `PLYPresentation` to the v6 cross-platform map. +/// On iOS we map `presentation.id` to `screenId` and keep `id` as alias (P1.1). +static NSDictionary *V6PresentationToMap(PLYPresentation *presentation) { + if (presentation == nil) { + return nil; + } + NSMutableDictionary *map = [NSMutableDictionary new]; + if (presentation.id != nil) { + map[@"screenId"] = presentation.id; + map[@"id"] = presentation.id; + } + if (presentation.placementId != nil) { + map[@"placementId"] = presentation.placementId; + } + if (presentation.audienceId != nil) { + map[@"audienceId"] = presentation.audienceId; + } + if (presentation.abTestId != nil) { + map[@"abTestId"] = presentation.abTestId; + } + if (presentation.abTestVariantId != nil) { + map[@"abTestVariantId"] = presentation.abTestVariantId; + } + if (presentation.language != nil) { + map[@"language"] = presentation.language; + } + map[@"type"] = @(presentation.type); + map[@"height"] = @(presentation.height); + if (presentation.plans != nil) { + NSMutableArray *plans = [NSMutableArray new]; + for (PLYPresentationPlan *plan in presentation.plans) { + [plans addObject:plan.asDictionary]; + } + map[@"plans"] = plans; + } + return map; +} + +/// Wrap an `NSError` into the v6 `PresentationError` shape. +static NSDictionary *V6ErrorToMap(NSError *error) { + if (error == nil) { + return nil; + } + NSMutableDictionary *map = [NSMutableDictionary new]; + map[@"code"] = @(error.code); + map[@"domain"] = error.domain ?: @""; + map[@"message"] = error.localizedDescription ?: @"Unknown error"; + return map; +} + +/// Convert a `PLYProductViewControllerResult` to the v6 ordinal that JS expects +/// for `PRESENTATION_DISMISSED.purchaseResult`. We keep the legacy ordinals +/// here because the TS helper `purchaseResultFromOrdinal` translates them to +/// the contract strings. +static NSNumber *V6PurchaseResultOrdinal(PLYProductViewControllerResult result) { + switch (result) { + case PLYProductViewControllerResultPurchased: return @(0); + case PLYProductViewControllerResultCancelled: return @(1); + case PLYProductViewControllerResultRestored: return @(2); + } + return nil; +} + +#pragma mark - Emitter access + +@interface PurchaselyRN () +- (void)sendEventWithName:(NSString *)name body:(id)body; +@end + +@implementation PurchaselyRN (V6) + +/// Wrapper around `sendEventWithName:body:` that ensures the bridge is observing. +/// If `shouldEmit` is NO the SDK is not active yet — drop the event silently. +- (void)v6EmitEvent:(NSString *)eventName body:(NSDictionary *)body { + if (!self.shouldEmit) { + return; + } + [self sendEventWithName:eventName body:body ?: @{}]; +} + +#pragma mark - Builder payload parsing + +/// Extract a `PLYPresentation` lookup spec from the builder payload sent by JS. +/// Returns the values resolved into the corresponding strings. +- (void)v6ExtractTargetsFromPayload:(NSDictionary *)payload + toPlacement:(NSString * __autoreleasing *)placementId + toPresentation:(NSString * __autoreleasing *)presentationId + toContentId:(NSString * __autoreleasing *)contentId { + if (payload[@"placementId"] != [NSNull null]) { + *placementId = payload[@"placementId"]; + } + // JS sends `screenId` as `presentationId` (cf. presentation.ts toNativePayload). + if (payload[@"presentationId"] != [NSNull null]) { + *presentationId = payload[@"presentationId"]; + } + if (payload[@"contentId"] != [NSNull null]) { + *contentId = payload[@"contentId"]; + } +} + +#pragma mark - v6Preload + +RCT_EXPORT_METHOD(v6Preload:(NSString *)requestId + payload:(NSDictionary *)payload + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject) { + V6EnsureInternalState(); + + NSString *placementId = nil; + NSString *presentationId = nil; + NSString *contentId = nil; + [self v6ExtractTargetsFromPayload:payload + toPlacement:&placementId + toPresentation:&presentationId + toContentId:&contentId]; + + __weak PurchaselyRN *weakSelf = self; + void (^onFetchCompletion)(PLYPresentation * _Nullable, NSError * _Nullable) = + ^(PLYPresentation * _Nullable presentation, NSError * _Nullable error) { + PurchaselyRN *strongSelf = weakSelf; + if (!strongSelf) { return; } + + NSMutableDictionary *event = [NSMutableDictionary new]; + event[@"requestId"] = requestId; + if (presentation != nil) { + event[@"presentation"] = V6PresentationToMap(presentation); + [PurchaselyRN.presentationsLoaded addObject:presentation]; + kV6PresentationsByRequest[requestId] = presentation; + } + if (error != nil) { + event[@"error"] = V6ErrorToMap(error); + } + [strongSelf v6EmitEvent:kV6EventLoaded body:event]; + }; + + dispatch_async(dispatch_get_main_queue(), ^{ + if (placementId != nil) { + [Purchasely fetchPresentationFor:placementId + contentId:contentId + fetchCompletion:onFetchCompletion + completion:nil + loadedCompletion:nil]; + } else if (presentationId != nil) { + // P1.1: `screenId` → `fetchPresentationWith:` on iOS. + [Purchasely fetchPresentationWith:presentationId + contentId:contentId + fetchCompletion:onFetchCompletion + completion:nil + loadedCompletion:nil]; + } else { + NSError *error = [NSError errorWithDomain:@"io.purchasely.v6" + code:400 + userInfo:@{NSLocalizedDescriptionKey: @"No placementId or screenId provided"}]; + onFetchCompletion(nil, error); + } + resolve(@(YES)); + }); +} + +#pragma mark - v6Display + +RCT_EXPORT_METHOD(v6Display:(NSString *)requestId + payload:(NSDictionary *)payload + transition:(NSDictionary *)transition + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject) { + V6EnsureInternalState(); + + NSString *placementId = nil; + NSString *presentationId = nil; + NSString *contentId = nil; + [self v6ExtractTargetsFromPayload:payload + toPlacement:&placementId + toPresentation:&presentationId + toContentId:&contentId]; + + __weak PurchaselyRN *weakSelf = self; + + // Captured for the close-flow: lets the dismissal handler send the + // dismissed event with the right outcome. + __block PLYPresentation *capturedPresentation = nil; + __block PLYProductViewControllerResult capturedResult = PLYProductViewControllerResultCancelled; + __block PLYPlan *capturedPlan = nil; + __block BOOL hasPurchaseOutcome = NO; + + void (^emitDismissed)(NSError * _Nullable) = ^(NSError * _Nullable error) { + PurchaselyRN *strongSelf = weakSelf; + if (!strongSelf) { return; } + NSMutableDictionary *body = [NSMutableDictionary new]; + body[@"requestId"] = requestId; + if (capturedPresentation != nil) { + body[@"presentation"] = V6PresentationToMap(capturedPresentation); + } + if (hasPurchaseOutcome) { + NSNumber *ordinal = V6PurchaseResultOrdinal(capturedResult); + if (ordinal != nil) { + body[@"purchaseResult"] = ordinal; + } + if (capturedPlan != nil) { + body[@"plan"] = [capturedPlan asDictionary]; + } + } + if (error != nil) { + body[@"error"] = V6ErrorToMap(error); + } + // closeReason stays absent on iOS until native exposes it (cf. P0.2). + [strongSelf v6EmitEvent:kV6EventDismissed body:body]; + [kV6PresentationsByRequest removeObjectForKey:requestId]; + }; + + void (^onFetchCompletion)(PLYPresentation * _Nullable, NSError * _Nullable) = + ^(PLYPresentation * _Nullable presentation, NSError * _Nullable error) { + PurchaselyRN *strongSelf = weakSelf; + if (!strongSelf) { return; } + + // Emit `onLoaded` (mirrors Android contract — preload+display share the + // same lifecycle on the JS side). + NSMutableDictionary *loaded = [NSMutableDictionary new]; + loaded[@"requestId"] = requestId; + if (presentation != nil) { + loaded[@"presentation"] = V6PresentationToMap(presentation); + } + if (error != nil) { + loaded[@"error"] = V6ErrorToMap(error); + } + [strongSelf v6EmitEvent:kV6EventLoaded body:loaded]; + + if (error != nil) { + // P0.4: synthesize an onPresented(null, error) since the native + // pipeline failed before the controller was shown. + NSMutableDictionary *presented = [NSMutableDictionary new]; + presented[@"requestId"] = requestId; + presented[@"error"] = V6ErrorToMap(error); + [strongSelf v6EmitEvent:kV6EventPresented body:presented]; + + emitDismissed(error); + return; + } + + if (presentation == nil) { + NSError *missing = [NSError errorWithDomain:@"io.purchasely.v6" + code:404 + userInfo:@{NSLocalizedDescriptionKey: @"Presentation not found"}]; + NSMutableDictionary *presented = [NSMutableDictionary new]; + presented[@"requestId"] = requestId; + presented[@"error"] = V6ErrorToMap(missing); + [strongSelf v6EmitEvent:kV6EventPresented body:presented]; + + emitDismissed(missing); + return; + } + + capturedPresentation = presentation; + kV6PresentationsByRequest[requestId] = presentation; + + // Emit onPresented (no native callback for it yet — we fire after the + // controller becomes available). + NSMutableDictionary *presented = [NSMutableDictionary new]; + presented[@"requestId"] = requestId; + presented[@"presentation"] = V6PresentationToMap(presentation); + [strongSelf v6EmitEvent:kV6EventPresented body:presented]; + + UIViewController *controller = presentation.controller; + if (controller == nil) { + NSError *err = [NSError errorWithDomain:@"io.purchasely.v6" + code:500 + userInfo:@{NSLocalizedDescriptionKey: @"Presentation has no controller"}]; + emitDismissed(err); + return; + } + + // Apply the transition `dismissible` flag if provided. + if ([transition isKindOfClass:[NSDictionary class]]) { + id dismissible = transition[@"dismissible"]; + if ([dismissible isKindOfClass:[NSNumber class]]) { + controller.modalInPresentation = ![dismissible boolValue]; + } + } + + strongSelf.presentedPresentationViewController = controller; + [Purchasely showController:controller type:PLYUIControllerTypeProductPage from:nil]; + }; + + void (^onResultCompletion)(PLYProductViewControllerResult, PLYPlan * _Nullable) = + ^(PLYProductViewControllerResult result, PLYPlan * _Nullable plan) { + capturedResult = result; + capturedPlan = plan; + hasPurchaseOutcome = YES; + emitDismissed(nil); + }; + + dispatch_async(dispatch_get_main_queue(), ^{ + if (placementId != nil) { + [Purchasely fetchPresentationFor:placementId + contentId:contentId + fetchCompletion:onFetchCompletion + completion:onResultCompletion + loadedCompletion:nil]; + } else if (presentationId != nil) { + [Purchasely fetchPresentationWith:presentationId + contentId:contentId + fetchCompletion:onFetchCompletion + completion:onResultCompletion + loadedCompletion:nil]; + } else { + NSError *error = [NSError errorWithDomain:@"io.purchasely.v6" + code:400 + userInfo:@{NSLocalizedDescriptionKey: @"No placementId or screenId provided"}]; + onFetchCompletion(nil, error); + } + resolve(@(YES)); + }); +} + +#pragma mark - v6Close / v6Back + +RCT_EXPORT_METHOD(v6Close:(NSString *)requestId) { + V6EnsureInternalState(); + dispatch_async(dispatch_get_main_queue(), ^{ + // Notify JS so the host app can react before the native dismissal happens. + [self v6EmitEvent:kV6EventCloseRequested body:@{ @"requestId": requestId ?: @"" }]; + self.presentedPresentationViewController = nil; + [Purchasely closeDisplayedPresentation]; + [kV6PresentationsByRequest removeObjectForKey:requestId]; + }); +} + +RCT_EXPORT_METHOD(v6Back:(NSString *)requestId) { + // The legacy iOS SDK does not expose a `back()` primitive on the + // presentation controller. Bridge contract says: noop with a warn. + RCTLogWarn(@"[v6] v6Back(%@) is not yet bridged on iOS", requestId); +} + +#pragma mark - Interceptors + +RCT_EXPORT_METHOD(v6RegisterInterceptor:(NSString *)kind) { + V6EnsureInternalState(); + [kV6InterceptorKinds addObject:kind]; + + // The iOS SDK exposes a single global interceptor — we wire it once and + // dispatch to JS only for the registered kinds. Re-installing the same + // block on each call is safe (the SDK replaces the previous one). + dispatch_async(dispatch_get_main_queue(), ^{ + __weak PurchaselyRN *weakSelf = self; + [Purchasely setPaywallActionsInterceptor:^(PLYPresentationAction action, + PLYPresentationActionParameters * _Nullable params, + PLYPresentationInfo * _Nullable infos, + void (^ _Nonnull onProcessActionHandler)(BOOL)) { + PurchaselyRN *strongSelf = weakSelf; + if (!strongSelf) { + onProcessActionHandler(YES); + return; + } + + NSString *actionKind = V6StringFromAction(action); + if (![kV6InterceptorKinds containsObject:actionKind]) { + // JS did not register this kind — fall through to native default. + onProcessActionHandler(YES); + return; + } + + NSString *callbackId = [[NSUUID UUID] UUIDString]; + 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); + }; + + // Serialize info + payload. + NSMutableDictionary *info = [NSMutableDictionary new]; + if (infos.contentId != nil) { + info[@"contentId"] = infos.contentId; + } + if (infos.presentationId != nil) { + // Surface the loaded PLYPresentation if we still have it cached. + PLYPresentation *cached = nil; + for (PLYPresentation *p in PurchaselyRN.presentationsLoaded) { + if ([p.id isEqualToString:infos.presentationId]) { + cached = p; + break; + } + } + NSMutableDictionary *presentationMap = [NSMutableDictionary new]; + presentationMap[@"screenId"] = infos.presentationId; + presentationMap[@"id"] = infos.presentationId; + if (infos.placementId != nil) { + presentationMap[@"placementId"] = infos.placementId; + } + if (cached != nil) { + NSDictionary *full = V6PresentationToMap(cached); + [presentationMap addEntriesFromDictionary:full]; + } + info[@"presentation"] = presentationMap; + } + + NSMutableDictionary *payloadOut = [NSMutableDictionary new]; + if (params != nil) { + switch (action) { + case PLYPresentationActionNavigate: { + payloadOut[@"url"] = params.url.absoluteString ?: @""; + if (params.title != nil) { + payloadOut[@"title"] = params.title; + } + break; + } + case PLYPresentationActionPurchase: { + if (params.plan != nil) { + payloadOut[@"plan"] = [params.plan asDictionary]; + } + if (params.promoOffer != nil) { + NSMutableDictionary *offer = [NSMutableDictionary new]; + if (params.promoOffer.vendorId != nil) { + offer[@"vendorId"] = params.promoOffer.vendorId; + } + if (params.promoOffer.storeOfferId != nil) { + offer[@"storeOfferId"] = params.promoOffer.storeOfferId; + } + payloadOut[@"offer"] = offer; + } + break; + } + case PLYPresentationActionClose: + case PLYPresentationActionCloseAll: { + // iOS has no closeReason yet — default to "button" + // (cf. contract: iOS closeReason always null/button until fix). + payloadOut[@"closeReason"] = @"button"; + break; + } + case PLYPresentationActionOpenPresentation: { + if (params.presentation != nil) { + payloadOut[@"presentationId"] = params.presentation; + } + break; + } + case PLYPresentationActionOpenPlacement: { + if (params.placement != nil) { + payloadOut[@"placementId"] = params.placement; + } + break; + } + case PLYPresentationActionWebCheckout: { + payloadOut[@"url"] = params.url.absoluteString ?: @""; + if (params.clientReferenceId != nil) { + payloadOut[@"clientReferenceId"] = params.clientReferenceId; + } + if (params.queryParameterKey != nil) { + payloadOut[@"queryParameterKey"] = params.queryParameterKey; + } + payloadOut[@"webCheckoutProvider"] = + V6StringFromWebCheckoutProvider(params.webCheckoutProvider); + break; + } + default: + break; + } + } + + NSMutableDictionary *event = [NSMutableDictionary new]; + event[@"requestId"] = @""; + event[@"callbackId"] = callbackId; + event[@"kind"] = actionKind; + event[@"info"] = info; + event[@"payload"] = payloadOut; + [strongSelf v6EmitEvent:kV6EventActionIntercepted body:event]; + }]; + }); +} + +RCT_EXPORT_METHOD(v6UnregisterInterceptor:(NSString *)kind) { + V6EnsureInternalState(); + [kV6InterceptorKinds removeObject:kind]; + if (kV6InterceptorKinds.count == 0) { + dispatch_async(dispatch_get_main_queue(), ^{ + [Purchasely setPaywallActionsInterceptor:nil]; + }); + } +} + +RCT_EXPORT_METHOD(v6CompleteInterceptor:(NSString *)callbackId result:(NSString *)result) { + V6EnsureInternalState(); + void (^cb)(NSString *) = kV6InterceptorCallbacks[callbackId]; + if (cb != nil) { + [kV6InterceptorCallbacks removeObjectForKey:callbackId]; + cb(result); + } +} + +#pragma mark - Start options + +RCT_EXPORT_METHOD(v6ApplyStartOptions:(NSDictionary *)options) { + if (![options isKindOfClass:[NSDictionary class]]) { return; } + id allowDeeplink = options[@"allowDeeplink"]; + if ([allowDeeplink isKindOfClass:[NSNumber class]]) { + dispatch_async(dispatch_get_main_queue(), ^{ + [Purchasely readyToOpenDeeplink:[allowDeeplink boolValue]]; + }); + } + // `allowCampaigns` is honored on Android via the consent manager; on iOS + // the equivalent is not exposed publicly yet — JS clients receive the value + // back through the start payload but iOS does not yet act on it. + id allowCampaigns = options[@"allowCampaigns"]; + if ([allowCampaigns isKindOfClass:[NSNumber class]] && ![allowCampaigns boolValue]) { + RCTLogWarn(@"[v6] allowCampaigns(false) is not bridged on iOS yet"); + } +} + +@end From 4f48b1fb21c02cb7152ba26c807832a4a1291bee Mon Sep 17 00:00:00 2001 From: Kevin Date: Thu, 28 May 2026 21:52:06 +0200 Subject: [PATCH 05/77] feat(ios): register v6 events on PurchaselyRN 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) --- packages/purchasely/ios/PurchaselyRN.m | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/purchasely/ios/PurchaselyRN.m b/packages/purchasely/ios/PurchaselyRN.m index 30c4190c..a808669c 100644 --- a/packages/purchasely/ios/PurchaselyRN.m +++ b/packages/purchasely/ios/PurchaselyRN.m @@ -10,6 +10,7 @@ #import #import #import "PurchaselyRN.h" +#import "PurchaselyRNV6.h" #import "Purchasely_Hybrid.h" #import "UIColor+PLYHelper.h" @@ -1400,7 +1401,19 @@ - (NSInteger) findIndexPresentationLoadedFor:(NSString * _Nullable)presentationI #pragma mark - Events - (NSArray *)supportedEvents { - return @[@"PURCHASELY_EVENTS", @"PURCHASE_LISTENER", @"USER_ATTRIBUTE_SET_LISTENER", @"USER_ATTRIBUTE_REMOVED_LISTENER"]; + return @[ + @"PURCHASELY_EVENTS", + @"PURCHASE_LISTENER", + @"USER_ATTRIBUTE_SET_LISTENER", + @"USER_ATTRIBUTE_REMOVED_LISTENER", + // v6 cross-platform bridge events. Names mirror the Android bridge so the + // same JS layer drives both platforms. See PurchaselyRNV6.m. + @"PURCHASELY_V6_LOADED", + @"PURCHASELY_V6_PRESENTED", + @"PURCHASELY_V6_CLOSE_REQUESTED", + @"PURCHASELY_V6_DISMISSED", + @"PURCHASELY_V6_ACTION_INTERCEPTED", + ]; } - (void)startObserving From bddd097822a6e745b440938db38ae83f1a67dd38 Mon Sep 17 00:00:00 2001 From: Kevin Date: Thu, 28 May 2026 21:52:59 +0200 Subject: [PATCH 06/77] chore(example): demonstrate v6 contract in example app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- example/src/App.tsx | 82 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/example/src/App.tsx b/example/src/App.tsx index 688bb19b..4b95b246 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -8,8 +8,12 @@ import Purchasely, { PLYDataProcessingLegalBasis, PLYDataProcessingPurpose, PLYPaywallAction, + PresentationBuilder, + PurchaselyBuilder, PurchaselyUserAttribute, RunningMode, + interceptAction, + removeAllActionInterceptors, } from 'react-native-purchasely' import { PaywallScreen } from './Paywall.tsx' @@ -308,9 +312,87 @@ function App(): React.JSX.Element { } } + // ------------------------------------------------------------------------- + // v6 builder demo. The v6 API is the cross-platform replacement for the + // legacy `Purchasely.start(...)` / `fetchPresentation` flow. It mirrors + // the Android-style chained builder. + // + // To opt in, uncomment the `setupPurchaselyV6()` call inside the + // `useEffect` below. + // ------------------------------------------------------------------------- + async function setupPurchaselyV6() { + try { + // Chained start — equivalent to the legacy `Purchasely.start({...})` + // but uses Android-style typed strings + chain options. + await PurchaselyBuilder.apiKey( + 'fcb39be4-2ba4-4db7-bde3-2a5a1e20745d' + ) + .appUserId('test-user-id') + .runningMode('full') + .logLevel('debug') + .allowDeeplink(true) + .allowCampaigns(true) + .storekitVersion('storeKit2') + .stores(['google']) + .start() + } catch (e) { + console.error('[v6] start failed:', e) + return + } + + // Register at least one interceptor — purchase action — per the v6 + // bridge contract. Handlers must return 'success' | 'failed' | + // 'notHandled'. + interceptAction('purchase', async (info, payload) => { + console.info('[v6] purchase intercepted', info, payload) + // Returning 'notHandled' lets the SDK perform its default + // purchase flow. Switch to 'success' / 'failed' if you handle + // the transaction yourself. + return 'notHandled' + }) + + // Build, present and react to lifecycle callbacks. `display()` + // resolves at DISMISS with a 5-field `PresentationOutcome`. + const request = PresentationBuilder.placement('ONBOARDING') + .contentId('content_123') + .onLoaded((presentation) => { + console.info('[v6] loaded', presentation.screenId) + }) + .onPresented((presentation, error) => { + if (error) { + console.error('[v6] presented error', error) + return + } + console.info('[v6] presented', presentation?.screenId) + }) + .onCloseRequested(() => { + console.info('[v6] close requested by host') + }) + .onDismissed((outcome) => { + console.info( + '[v6] dismissed', + 'purchaseResult=', outcome.purchaseResult, + 'plan=', outcome.plan?.vendorId, + 'closeReason=', outcome.closeReason, + 'error=', outcome.error?.message + ) + }) + .build() + + const outcome = await request.display({ type: 'fullScreen' }) + console.info('[v6] display() resolved with outcome', outcome) + + // Equivalent helper to detach every interceptor previously registered. + removeAllActionInterceptors() + } + useEffect(() => { setupPurchasely() fetchPresentation() + // Uncomment to exercise the v6 builder pipeline alongside the legacy + // v5 example flow above. The two are mutually exclusive at runtime: + // calling start() twice will return the cached initialization. + // setupPurchaselyV6() }, []) return ( From ffd25e555ac256b119e88746a0351c137e6be832 Mon Sep 17 00:00:00 2001 From: Kevin Date: Thu, 28 May 2026 21:54:12 +0200 Subject: [PATCH 07/77] docs: add v6 migration guide, changelog and bump to 6.0.0-beta.0 - 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) --- packages/purchasely/CHANGELOG.md | 66 +++++++++++++++++ packages/purchasely/README.md | 118 +++++++++++++++++++++++++++++++ packages/purchasely/package.json | 2 +- 3 files changed, 185 insertions(+), 1 deletion(-) create mode 100644 packages/purchasely/CHANGELOG.md diff --git a/packages/purchasely/CHANGELOG.md b/packages/purchasely/CHANGELOG.md new file mode 100644 index 00000000..3412fce6 --- /dev/null +++ b/packages/purchasely/CHANGELOG.md @@ -0,0 +1,66 @@ +# Changelog + +All notable changes to `react-native-purchasely` are documented in this file. + +## [6.0.0-beta.0] — Unreleased + +### Added — v6 cross-platform builder API + +The v6 façade is a chainable, type-safe API that mirrors the v6 Android/iOS +SDKs. It ships alongside the legacy v5 API for backwards compatibility — both +can be used in the same app during migration. + +- **`PurchaselyBuilder`** chained start (`apiKey(...).runningMode(...).start()`) + replacing the multi-argument `Purchasely.start({...})`. +- **`PresentationBuilder`** with `.placement(id)`, `.screen(id)`, `.default()` + factory methods, callback chain (`onLoaded`, `onPresented`, + `onCloseRequested`, `onDismissed`), and `.build()` returning a + `PresentationRequest`. +- **`PresentationRequest`** with `.preload()`, `.display(transition?)`, + `.close()`, `.back()`. The `display()` Promise resolves at **dismiss** + (not at trigger) with a 5-field `PresentationOutcome`. +- **`PresentationOutcome`**: `{ presentation, purchaseResult, plan, closeReason, + error }`. Exclusion rule: `error != null ⇒ closeReason == null`. +- **`interceptAction(kind, handler)`** with typed payloads per action kind + (`navigate`, `purchase`, `close`, `closeAll`, `openPresentation`, + `openPlacement`, `webCheckout`, `login`, `restore`, `promoCode`). Handler + returns `'success' | 'failed' | 'notHandled'`. +- **`removeActionInterceptor(kind)`** / **`removeAllActionInterceptors()`**. +- **5 new native events**: `PURCHASELY_V6_LOADED`, `PURCHASELY_V6_PRESENTED`, + `PURCHASELY_V6_CLOSE_REQUESTED`, `PURCHASELY_V6_DISMISSED`, + `PURCHASELY_V6_ACTION_INTERCEPTED`. + +### Native bridges + +- **Android**: `PurchaselyV6Bridge` (Kotlin object) wired to the v6 SDK builder + (`PLYPresentationBase.builder()`) — direct mapping of every contract item. +- **iOS**: `PurchaselyRN (V6)` category implemented on top of the legacy + `fetchPresentationFor:contentId:fetchCompletion:completion:` while the + native v6 SDK lands. The bridge synthesizes the 5-field outcome and the + `onPresented(presentation?, error?)` callback per contract workarounds. + `closeReason` stays `null` on iOS until the native pipeline exposes it. + +### Breaking changes + +None — the legacy v5 API stays exported and behaves exactly as in 5.x. New code +should use the v6 builders. + +### Deprecated + +- `Purchasely.start({...})` → use `PurchaselyBuilder.apiKey(...).start()`. +- `Purchasely.presentPresentationForPlacement({...})` → use + `PresentationBuilder.placement(...).build().display()`. +- `Purchasely.setPaywallActionInterceptorCallback(...)` → use + `interceptAction(kind, handler)` per action kind. + +### iOS TODOs (tracked in the bridge code) + +- Wire `closeReason` once the native iOS SDK exposes the dismissal reason. +- Map `screenId` directly when iOS adds a dedicated property (currently + aliased to `presentation.id`). +- Drop the synthesized `onPresented` once the native callback ships. + +## [5.7.3] and earlier + +See [git history](https://github.com/Purchasely/Purchasely-ReactNative/commits/main) +for releases prior to v6. diff --git a/packages/purchasely/README.md b/packages/purchasely/README.md index 0414415b..39da54cc 100644 --- a/packages/purchasely/README.md +++ b/packages/purchasely/README.md @@ -161,6 +161,124 @@ export const PaywallScreen: React.FC> = ({ navigatio }; ``` +## 🆕 Migration to v6.x + +`react-native-purchasely@6` introduces a cross-platform builder API that +mirrors the native Android/iOS v6 SDKs. The legacy v5 API stays available +during the transition — the v6 façade ships side-by-side and is the +recommended way to integrate going forward. + +The v6 contract is documented in +`reports/v6-presentation-comparison-v3-claude/BRIDGE-CONTRACT.md` (internal). + +### Initialization + +```ts +// v5 (deprecated, still works) +await Purchasely.start({ + apiKey: 'YOUR_API_KEY', + androidStores: ['Google'], + storeKit1: false, + userId: 'user_id', + logLevel: LogLevels.DEBUG, + runningMode: RunningMode.FULL, +}) + +// v6 +import { PurchaselyBuilder } from 'react-native-purchasely' + +await PurchaselyBuilder.apiKey('YOUR_API_KEY') + .appUserId('user_id') + .runningMode('full') // 'observer' | 'full' + .logLevel('debug') // 'debug' | 'info' | 'warn' | 'error' + .allowDeeplink(true) + .allowCampaigns(true) + .storekitVersion('storeKit2') // iOS only + .stores(['google']) // Android only + .start() +``` + +### Paywall display + +```ts +// v5 +const result = await Purchasely.presentPresentationForPlacement({ + placementVendorId: 'ONBOARDING', + isFullscreen: true, +}) + +// v6 +import { PresentationBuilder } from 'react-native-purchasely' + +const request = PresentationBuilder.placement('ONBOARDING') + .contentId('content_123') + .onLoaded((presentation) => { /* preload complete */ }) + .onPresented((presentation, error) => { /* presentation visible (or error) */ }) + .onCloseRequested(() => { /* user asked to close */ }) + .onDismissed((outcome) => { /* see outcome contract below */ }) + .build() + +// Resolves at DISMISS with the 5-field outcome (not at trigger). +const outcome = await request.display({ type: 'fullScreen' }) + +// Or use `.screen('SCREEN_ID')` to target a presentation directly. +// Or `.default()` to use the SDK default placement. +``` + +### Action interceptor + +```ts +// v5: single global handler dispatched by switch on `action` +Purchasely.setPaywallActionInterceptorCallback((result) => { + switch (result.action) { + case PLYPaywallAction.PURCHASE: + Purchasely.onProcessAction(true) + break + /* ... */ + } +}) + +// v6: one typed interceptor per action kind, returning an InterceptResult +import { interceptAction } from 'react-native-purchasely' + +interceptAction('purchase', async ({ presentation }, payload) => { + if (payload?.kind === 'purchase') { + console.log('user wants to buy', payload.plan.vendorId) + } + // 'success' | 'failed' | 'notHandled' + // 'notHandled' lets the SDK run its default flow for the action. + return 'notHandled' +}) + +interceptAction('navigate', async (_info, payload) => { + if (payload?.kind === 'navigate') { + Linking.openURL(payload.url) + return 'success' + } + return 'notHandled' +}) +``` + +### Outcome (5 fields) + +The v6 `PresentationOutcome` exposes the full close context: + +```ts +interface PresentationOutcome { + presentation?: Presentation | null + purchaseResult?: 'purchased' | 'cancelled' | 'restored' | null + plan?: PurchaselyPlan | null + closeReason?: 'button' | 'backSystem' | 'programmatic' | null + error?: PresentationError | null +} +``` + +Exclusion rule: `error != null` ⇒ `closeReason == null`. + +> **iOS notes (temporary).** Until the iOS native v6 lands, the bridge +> synthesizes the 5-field outcome from the legacy callbacks. `closeReason` +> stays `null` and `screenId` is mapped from `presentation.id`. + ## 📖 Documentation A complete documentation is available on our website: [Purchasely Docs](https://docs.purchasely.com/quick-start/sdk-installation/react-native-sdk). diff --git a/packages/purchasely/package.json b/packages/purchasely/package.json index 33b16de3..65fb5100 100644 --- a/packages/purchasely/package.json +++ b/packages/purchasely/package.json @@ -1,7 +1,7 @@ { "name": "react-native-purchasely", "title": "Purchasely React Native", - "version": "6.0.0", + "version": "6.0.0-beta.0", "description": "Purchasely is a solution to ease the integration and boost your In-App Purchase & Subscriptions on the App Store, Google Play Store and Huawei App Gallery.", "source": "./src/index.tsx", "main": "./lib/commonjs/index.js", From 318bf18c11db306843e8887e8fb028f637377ed0 Mon Sep 17 00:00:00 2001 From: Kevin Date: Thu, 28 May 2026 22:24:45 +0200 Subject: [PATCH 08/77] =?UTF-8?q?test(v6):=20add=20integration=20tests=20f?= =?UTF-8?q?or=20fa=C3=A7ade=20=E2=86=94=20native=20bridge=20contract?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../src/__tests__/v6.integration.test.ts | 319 ++++++++++++++++++ 1 file changed, 319 insertions(+) create mode 100644 packages/purchasely/src/__tests__/v6.integration.test.ts diff --git a/packages/purchasely/src/__tests__/v6.integration.test.ts b/packages/purchasely/src/__tests__/v6.integration.test.ts new file mode 100644 index 00000000..f1057de9 --- /dev/null +++ b/packages/purchasely/src/__tests__/v6.integration.test.ts @@ -0,0 +1,319 @@ +/** + * Integration tests for the v6 cross-platform façade. + * + * Validates the JS ↔ native contract documented in + * `reports/v6-presentation-comparison-v3-claude/BRIDGE-CONTRACT.md`: + * - PresentationBuilder → invokes `v6Preload`/`v6Display` with expected args + * - Lifecycle events (LOADED, PRESENTED, CLOSE_REQUESTED, DISMISSED) flow + * through `NativeEventEmitter` and resolve the public promises/callbacks + * - Outcome carries the 5 fields (presentation, purchaseResult, plan, + * closeReason, error) + * - Action interceptor lifecycle: register → trigger → resolve back to native + */ + +jest.mock('react-native', () => { + const listeners: Record void>> = {}; + const emit = (eventName: string, payload: any) => { + (listeners[eventName] ?? []).forEach((l) => l(payload)); + }; + + const Purchasely = { + getConstants: () => ({ + logLevelDebug: 0, + logLevelInfo: 1, + logLevelWarn: 2, + logLevelError: 3, + productResultPurchased: 0, + productResultCancelled: 1, + productResultRestored: 2, + }), + v6Preload: jest.fn().mockResolvedValue(undefined), + v6Display: jest.fn().mockResolvedValue(undefined), + v6Close: jest.fn(), + v6Back: jest.fn(), + v6RegisterInterceptor: jest.fn(), + v6UnregisterInterceptor: jest.fn(), + v6CompleteInterceptor: jest.fn(), + v6ApplyStartOptions: jest.fn(), + start: jest.fn().mockResolvedValue(true), + readyToOpenDeeplink: jest.fn(), + addListener: jest.fn(), + removeListeners: jest.fn(), + // Exposed only for the integration test — not in production native. + __testEmit: emit, + __testResetListeners: () => { + Object.keys(listeners).forEach((k) => (listeners[k] = [])); + }, + }; + + return { + NativeModules: { Purchasely }, + NativeEventEmitter: jest.fn().mockImplementation(() => ({ + addListener: (name: string, cb: (event: any) => void) => { + listeners[name] = listeners[name] ?? []; + listeners[name].push(cb); + return { + remove: () => { + listeners[name] = (listeners[name] ?? []).filter((l) => l !== cb); + }, + }; + }, + removeAllListeners: (name?: string) => { + if (name) listeners[name] = []; + }, + })), + Platform: { OS: 'ios', select: (obj: any) => obj.ios ?? obj.default }, + }; +}); + +import { NativeModules } from 'react-native'; +import { + PresentationBuilder, + interceptAction, + removeActionInterceptor, + PURCHASELY_V6_EVENTS, +} from '../v6'; + +const native = NativeModules.Purchasely as any; +const emit = native.__testEmit as (e: string, p: any) => void; + +const fakePresentationPayload = { + id: 'screen-abc', + placementId: 'home', + contentId: 'content-1', + type: 'normal', + height: 720, + language: 'fr', + plans: [], +}; + +describe('v6 façade · integration with native bridge', () => { + beforeEach(() => { + native.v6Preload.mockClear(); + native.v6Display.mockClear(); + native.v6Close.mockClear(); + native.v6Back.mockClear(); + native.v6RegisterInterceptor.mockClear(); + native.v6UnregisterInterceptor.mockClear(); + native.v6CompleteInterceptor.mockClear(); + native.__testResetListeners(); + }); + + describe('PresentationBuilder.placement(...).preload()', () => { + it('invokes v6Preload with placementId + contentId payload', async () => { + const req = PresentationBuilder.placement('home') + .contentId('content-1') + .build(); + + const preloadPromise = req.preload(); + // The native call must have been issued synchronously. + expect(native.v6Preload).toHaveBeenCalledTimes(1); + const [requestId, payload] = native.v6Preload.mock.calls[0]; + expect(typeof requestId).toBe('string'); + expect(requestId).toMatch(/^v6_req_/); + expect(payload).toMatchObject({ + placementId: 'home', + contentId: 'content-1', + }); + + // Simulate native success. + emit(PURCHASELY_V6_EVENTS.LOADED, { + requestId, + presentation: fakePresentationPayload, + }); + + const presentation = await preloadPromise; + expect(presentation.screenId).toBe('screen-abc'); + expect(presentation.placementId).toBe('home'); + }); + + it('rejects when native emits LOADED with an error', async () => { + const req = PresentationBuilder.placement('home').build(); + const preloadPromise = req.preload(); + const [requestId] = native.v6Preload.mock.calls[0]; + + emit(PURCHASELY_V6_EVENTS.LOADED, { + requestId, + presentation: null, + error: { code: 'NET', message: 'offline' }, + }); + + await expect(preloadPromise).rejects.toMatchObject({ + code: 'NET', + message: 'offline', + }); + }); + }); + + describe('PresentationBuilder.screen(...).build()', () => { + it('maps screenId → native presentationId field (bridge mapping P1.1)', () => { + const req = PresentationBuilder.screen('screen-xyz').build(); + req.preload(); + + expect(native.v6Preload).toHaveBeenCalledTimes(1); + const [, payload] = native.v6Preload.mock.calls[0]; + // Contract P1.1 — JS façade uses `screenId`, but the native bridge + // contract still uses `presentationId` (iOS native API name) until + // the iOS SDK renames it. The TS layer maps the two transparently. + expect(payload.presentationId).toBe('screen-xyz'); + expect(payload.placementId).toBeNull(); + }); + }); + + describe('PresentationRequest.display() — outcome 5 fields', () => { + it('resolves with the full outcome at DISMISS (not at trigger)', async () => { + let presentedPayload: any = null; + let closeRequestedFired = false; + + const req = PresentationBuilder.placement('home') + .onPresented((p, err) => { + presentedPayload = { p, err }; + }) + .onCloseRequested(() => { + closeRequestedFired = true; + }) + .build(); + + const displayPromise = req.display({ type: 'modal' }); + expect(native.v6Display).toHaveBeenCalledTimes(1); + const [requestId, , transition] = native.v6Display.mock.calls[0]; + expect(transition).toMatchObject({ type: 'modal' }); + + // PRESENTED first — must NOT resolve the display promise + // (contract P0.3 — bridge waits for DISMISSED). + emit(PURCHASELY_V6_EVENTS.PRESENTED, { + requestId, + presentation: fakePresentationPayload, + }); + expect(presentedPayload).not.toBeNull(); + expect(presentedPayload.p.screenId).toBe('screen-abc'); + + emit(PURCHASELY_V6_EVENTS.CLOSE_REQUESTED, { requestId }); + expect(closeRequestedFired).toBe(true); + + // Now DISMISSED — promise resolves with full outcome. + emit(PURCHASELY_V6_EVENTS.DISMISSED, { + requestId, + presentation: fakePresentationPayload, + purchaseResult: 0, // purchased (ordinal mapping) + plan: { vendorId: 'plan-monthly' }, + closeReason: 'button', + }); + + const outcome = await displayPromise; + expect(outcome.purchaseResult).toBe('purchased'); + expect(outcome.closeReason).toBe('button'); + expect(outcome.error).toBeFalsy(); + expect(outcome.presentation?.screenId).toBe('screen-abc'); + expect(outcome.plan).toMatchObject({ vendorId: 'plan-monthly' }); + }); + + it('forwards onPresented(null, error) when PRESENTED carries an error', () => { + let presentedPayload: any = null; + const req = PresentationBuilder.placement('home') + .onPresented((p, err) => { + presentedPayload = { p, err }; + }) + .build(); + req.display(); + const [requestId] = native.v6Display.mock.calls[0]; + + // Contract P0.4 — error path may carry an error on PRESENTED. + emit(PURCHASELY_V6_EVENTS.PRESENTED, { + requestId, + presentation: null, + error: { message: 'render failed' }, + }); + + expect(presentedPayload.p).toBeNull(); + expect(presentedPayload.err).toMatchObject({ message: 'render failed' }); + }); + + it('returns an outcome.error envelope when DISMISSED carries an error', async () => { + const req = PresentationBuilder.placement('home').build(); + const promise = req.display(); + const [requestId] = native.v6Display.mock.calls[0]; + + emit(PURCHASELY_V6_EVENTS.DISMISSED, { + requestId, + error: { code: 'X', message: 'oops' }, + }); + + const outcome = await promise; + expect(outcome.error).toMatchObject({ code: 'X', message: 'oops' }); + expect(outcome.closeReason).toBeFalsy(); + }); + }); + + describe('Action interceptor lifecycle', () => { + it('registers, dispatches and resolves an interceptor end-to-end', async () => { + const handler = jest.fn().mockResolvedValue('success' as const); + interceptAction('purchase', handler); + expect(native.v6RegisterInterceptor).toHaveBeenCalledWith('purchase'); + + emit(PURCHASELY_V6_EVENTS.ACTION_INTERCEPTED, { + requestId: 'req-1', + callbackId: 'cb-1', + kind: 'purchase', + info: { contentId: 'c1' }, + payload: { plan: { vendorId: 'monthly' } }, + }); + + await new Promise((r) => setImmediate(r)); + + expect(handler).toHaveBeenCalledTimes(1); + const [info, payload] = handler.mock.calls[0]; + expect(info).toMatchObject({ contentId: 'c1' }); + expect(payload).toMatchObject({ + kind: 'purchase', + plan: { vendorId: 'monthly' }, + }); + expect(native.v6CompleteInterceptor).toHaveBeenCalledWith('cb-1', 'success'); + }); + + it('does not auto-resolve orphan events (native must time out)', async () => { + // No JS interceptor registered for 'restore' — when the native + // bridge emits the event nobody filters it in, so the bridge layer + // does NOT post a result back. Native is expected to handle the + // timeout / default behavior on its side. + // (Documented as a TODO in the v6 contract — a global JS fallback + // could be added later if native does not handle it.) + emit(PURCHASELY_V6_EVENTS.ACTION_INTERCEPTED, { + requestId: 'req-2', + callbackId: 'cb-orphan', + kind: 'restore', + info: {}, + }); + await new Promise((r) => setImmediate(r)); + + expect(native.v6CompleteInterceptor).not.toHaveBeenCalled(); + }); + + it('dispatches only to the matching kind (cross-kind isolation)', async () => { + const purchaseHandler = jest.fn().mockResolvedValue('success' as const); + const loginHandler = jest.fn().mockResolvedValue('success' as const); + interceptAction('purchase', purchaseHandler); + interceptAction('login', loginHandler); + + emit(PURCHASELY_V6_EVENTS.ACTION_INTERCEPTED, { + requestId: 'req-3', + callbackId: 'cb-3', + kind: 'purchase', + info: {}, + payload: { plan: { vendorId: 'monthly' } }, + }); + await new Promise((r) => setImmediate(r)); + + expect(purchaseHandler).toHaveBeenCalledTimes(1); + expect(loginHandler).not.toHaveBeenCalled(); + expect(native.v6CompleteInterceptor).toHaveBeenCalledWith('cb-3', 'success'); + }); + + it('removeActionInterceptor calls the native unregister', () => { + interceptAction('login', jest.fn()); + native.v6RegisterInterceptor.mockClear(); + removeActionInterceptor('login'); + expect(native.v6UnregisterInterceptor).toHaveBeenCalledWith('login'); + }); + }); +}); From 2767d92242d8dac9596ba73cd9d0bc65dbf2830a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 28 May 2026 22:18:32 +0000 Subject: [PATCH 09/77] fix(v6): address Greptile review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- .gitignore | 3 +- .../v6/PurchaselyV6Module.kt | 25 +++-- packages/purchasely/ios/PurchaselyRNV6.m | 100 ++++++++++++++---- packages/purchasely/src/v6/presentation.ts | 10 +- 4 files changed, 107 insertions(+), 31 deletions(-) diff --git a/.gitignore b/.gitignore index caac6189..0d94b3d5 100644 --- a/.gitignore +++ b/.gitignore @@ -88,6 +88,7 @@ nitrogen/ .nx/cache -.nx/workspace-datajest_dx/ +.nx/workspace-data +jest_dx/ node-compile-cache/ **/coverage/ diff --git a/packages/purchasely/android/src/main/java/com/reactnativepurchasely/v6/PurchaselyV6Module.kt b/packages/purchasely/android/src/main/java/com/reactnativepurchasely/v6/PurchaselyV6Module.kt index 006fbfb0..df221aad 100644 --- a/packages/purchasely/android/src/main/java/com/reactnativepurchasely/v6/PurchaselyV6Module.kt +++ b/packages/purchasely/android/src/main/java/com/reactnativepurchasely/v6/PurchaselyV6Module.kt @@ -286,13 +286,11 @@ object PurchaselyV6Bridge { promise.resolve(true) } catch (e: Throwable) { - // Synthesize a dismissed event so the JS side resolves the display Promise. - val payload = Arguments.createMap() - payload.putString("requestId", requestId) - payload.putMap("error", Arguments.createMap().apply { - putString("message", e.message ?: "Display failed") - }) - sendEvent(reactContext, EVENT_DISMISSED, payload) + // Reject only — the JS `.catch` on v6Display synthesizes the dismissed + // outcome (onPresented(null, error) + onDismissed), mirroring the iOS + // error path. Emitting a DISMISSED event here as well would invoke the + // user-supplied onDismissed callback twice (the event listener and the + // promise rejection both settle the display flow). activeRequests.remove(requestId) promise.reject("v6_display_failure", e.message, e) } @@ -301,8 +299,17 @@ object PurchaselyV6Bridge { @JvmStatic fun close(requestId: String) { activeRequests.remove(requestId) - // Closing all screens is the closest match — the SDK v6 does not yet - // expose a per-request close. + // The SDK v6 does not yet expose a per-request close, so this dismisses + // *every* displayed presentation, not just `requestId`. Warn the host when + // other requests are still active so a stacked presentation (e.g. a product + // page inside an onboarding flow) being torn down is not a silent surprise. + if (activeRequests.isNotEmpty()) { + PLYLogger.w( + "[v6] close($requestId) dismisses ALL displayed presentations " + + "(per-request close is not yet supported by the native SDK); " + + "${activeRequests.size} other active request(s) will also be closed." + ) + } Purchasely.closeAllScreens() } diff --git a/packages/purchasely/ios/PurchaselyRNV6.m b/packages/purchasely/ios/PurchaselyRNV6.m index d88cff36..90b0b131 100644 --- a/packages/purchasely/ios/PurchaselyRNV6.m +++ b/packages/purchasely/ios/PurchaselyRNV6.m @@ -48,6 +48,11 @@ /// interceptor itself is global (`setPaywallActionsInterceptor`) — we only fire /// the JS event when the action kind matches a registered one. static NSMutableSet *kV6InterceptorKinds; +/// Serialises every access to the three mutable collections above. RN bridge +/// methods run on a background queue while the interceptor block / completions +/// run on the main queue; `NSMutable*` is not thread-safe, so all reads and +/// writes are guarded by `@synchronized(kV6StateLock)`. +static NSObject *kV6StateLock; static void V6EnsureInternalState(void) { static dispatch_once_t onceToken; @@ -55,6 +60,7 @@ static void V6EnsureInternalState(void) { kV6PresentationsByRequest = [NSMutableDictionary new]; kV6InterceptorCallbacks = [NSMutableDictionary new]; kV6InterceptorKinds = [NSMutableSet new]; + kV6StateLock = [NSObject new]; }); } @@ -181,7 +187,8 @@ - (void)v6EmitEvent:(NSString *)eventName body:(NSDictionary *)body { - (void)v6ExtractTargetsFromPayload:(NSDictionary *)payload toPlacement:(NSString * __autoreleasing *)placementId toPresentation:(NSString * __autoreleasing *)presentationId - toContentId:(NSString * __autoreleasing *)contentId { + toContentId:(NSString * __autoreleasing *)contentId + toIsDefault:(BOOL *)isDefault { if (payload[@"placementId"] != [NSNull null]) { *placementId = payload[@"placementId"]; } @@ -192,6 +199,13 @@ - (void)v6ExtractTargetsFromPayload:(NSDictionary *)payload if (payload[@"contentId"] != [NSNull null]) { *contentId = payload[@"contentId"]; } + // `PresentationBuilder.default()` sends `isDefault: true` with no placement / + // screen — route it to the SDK's default presentation (cf. legacy + // `fetchPresentation` which falls back to `fetchPresentationWith:nil`). + id isDefaultValue = payload[@"isDefault"]; + if ([isDefaultValue isKindOfClass:[NSNumber class]]) { + *isDefault = [isDefaultValue boolValue]; + } } #pragma mark - v6Preload @@ -205,10 +219,12 @@ - (void)v6ExtractTargetsFromPayload:(NSDictionary *)payload NSString *placementId = nil; NSString *presentationId = nil; NSString *contentId = nil; + BOOL isDefault = NO; [self v6ExtractTargetsFromPayload:payload toPlacement:&placementId toPresentation:&presentationId - toContentId:&contentId]; + toContentId:&contentId + toIsDefault:&isDefault]; __weak PurchaselyRN *weakSelf = self; void (^onFetchCompletion)(PLYPresentation * _Nullable, NSError * _Nullable) = @@ -221,7 +237,9 @@ - (void)v6ExtractTargetsFromPayload:(NSDictionary *)payload if (presentation != nil) { event[@"presentation"] = V6PresentationToMap(presentation); [PurchaselyRN.presentationsLoaded addObject:presentation]; - kV6PresentationsByRequest[requestId] = presentation; + @synchronized (kV6StateLock) { + kV6PresentationsByRequest[requestId] = presentation; + } } if (error != nil) { event[@"error"] = V6ErrorToMap(error); @@ -243,6 +261,14 @@ - (void)v6ExtractTargetsFromPayload:(NSDictionary *)payload fetchCompletion:onFetchCompletion completion:nil loadedCompletion:nil]; + } else if (isDefault) { + // Default presentation: iOS resolves it via `fetchPresentationWith:nil` + // (mirrors the legacy `fetchPresentation` fallback path). + [Purchasely fetchPresentationWith:nil + contentId:contentId + fetchCompletion:onFetchCompletion + completion:nil + loadedCompletion:nil]; } else { NSError *error = [NSError errorWithDomain:@"io.purchasely.v6" code:400 @@ -265,10 +291,12 @@ - (void)v6ExtractTargetsFromPayload:(NSDictionary *)payload NSString *placementId = nil; NSString *presentationId = nil; NSString *contentId = nil; + BOOL isDefault = NO; [self v6ExtractTargetsFromPayload:payload toPlacement:&placementId toPresentation:&presentationId - toContentId:&contentId]; + toContentId:&contentId + toIsDefault:&isDefault]; __weak PurchaselyRN *weakSelf = self; @@ -301,7 +329,9 @@ - (void)v6ExtractTargetsFromPayload:(NSDictionary *)payload } // closeReason stays absent on iOS until native exposes it (cf. P0.2). [strongSelf v6EmitEvent:kV6EventDismissed body:body]; - [kV6PresentationsByRequest removeObjectForKey:requestId]; + @synchronized (kV6StateLock) { + [kV6PresentationsByRequest removeObjectForKey:requestId]; + } }; void (^onFetchCompletion)(PLYPresentation * _Nullable, NSError * _Nullable) = @@ -347,7 +377,9 @@ - (void)v6ExtractTargetsFromPayload:(NSDictionary *)payload } capturedPresentation = presentation; - kV6PresentationsByRequest[requestId] = presentation; + @synchronized (kV6StateLock) { + kV6PresentationsByRequest[requestId] = presentation; + } // Emit onPresented (no native callback for it yet — we fire after the // controller becomes available). @@ -398,6 +430,14 @@ - (void)v6ExtractTargetsFromPayload:(NSDictionary *)payload fetchCompletion:onFetchCompletion completion:onResultCompletion loadedCompletion:nil]; + } else if (isDefault) { + // Default presentation: iOS resolves it via `fetchPresentationWith:nil` + // (mirrors the legacy `fetchPresentation` fallback path). + [Purchasely fetchPresentationWith:nil + contentId:contentId + fetchCompletion:onFetchCompletion + completion:onResultCompletion + loadedCompletion:nil]; } else { NSError *error = [NSError errorWithDomain:@"io.purchasely.v6" code:400 @@ -417,7 +457,9 @@ - (void)v6ExtractTargetsFromPayload:(NSDictionary *)payload [self v6EmitEvent:kV6EventCloseRequested body:@{ @"requestId": requestId ?: @"" }]; self.presentedPresentationViewController = nil; [Purchasely closeDisplayedPresentation]; - [kV6PresentationsByRequest removeObjectForKey:requestId]; + @synchronized (kV6StateLock) { + [kV6PresentationsByRequest removeObjectForKey:requestId]; + } }); } @@ -431,7 +473,9 @@ - (void)v6ExtractTargetsFromPayload:(NSDictionary *)payload RCT_EXPORT_METHOD(v6RegisterInterceptor:(NSString *)kind) { V6EnsureInternalState(); - [kV6InterceptorKinds addObject:kind]; + @synchronized (kV6StateLock) { + [kV6InterceptorKinds addObject:kind]; + } // The iOS SDK exposes a single global interceptor — we wire it once and // dispatch to JS only for the registered kinds. Re-installing the same @@ -449,20 +493,26 @@ - (void)v6ExtractTargetsFromPayload:(NSDictionary *)payload } NSString *actionKind = V6StringFromAction(action); - if (![kV6InterceptorKinds containsObject:actionKind]) { + BOOL kindRegistered; + @synchronized (kV6StateLock) { + kindRegistered = [kV6InterceptorKinds containsObject:actionKind]; + } + if (!kindRegistered) { // JS did not register this kind — fall through to native default. onProcessActionHandler(YES); return; } NSString *callbackId = [[NSUUID UUID] UUIDString]; - 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); - }; + @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); + }; + } // Serialize info + payload. NSMutableDictionary *info = [NSMutableDictionary new]; @@ -566,8 +616,12 @@ - (void)v6ExtractTargetsFromPayload:(NSDictionary *)payload RCT_EXPORT_METHOD(v6UnregisterInterceptor:(NSString *)kind) { V6EnsureInternalState(); - [kV6InterceptorKinds removeObject:kind]; - if (kV6InterceptorKinds.count == 0) { + BOOL noKindsLeft; + @synchronized (kV6StateLock) { + [kV6InterceptorKinds removeObject:kind]; + noKindsLeft = (kV6InterceptorKinds.count == 0); + } + if (noKindsLeft) { dispatch_async(dispatch_get_main_queue(), ^{ [Purchasely setPaywallActionsInterceptor:nil]; }); @@ -576,9 +630,15 @@ - (void)v6ExtractTargetsFromPayload:(NSDictionary *)payload RCT_EXPORT_METHOD(v6CompleteInterceptor:(NSString *)callbackId result:(NSString *)result) { V6EnsureInternalState(); - void (^cb)(NSString *) = kV6InterceptorCallbacks[callbackId]; + void (^cb)(NSString *) = nil; + @synchronized (kV6StateLock) { + cb = kV6InterceptorCallbacks[callbackId]; + if (cb != nil) { + [kV6InterceptorCallbacks removeObjectForKey:callbackId]; + } + } + // Invoke outside the lock — the callback re-enters the SDK's action handler. if (cb != nil) { - [kV6InterceptorCallbacks removeObjectForKey:callbackId]; cb(result); } } diff --git a/packages/purchasely/src/v6/presentation.ts b/packages/purchasely/src/v6/presentation.ts index 458e039d..768a6eb0 100644 --- a/packages/purchasely/src/v6/presentation.ts +++ b/packages/purchasely/src/v6/presentation.ts @@ -349,7 +349,15 @@ export class PresentationRequest { return this; } - /** Programmatically close the presentation if it is currently visible. */ + /** + * Programmatically close the presentation if it is currently visible. + * + * @remarks + * The native SDK does not yet expose a per-request close, so this currently + * dismisses **all** displayed presentations, not only this request. If your + * app stacks presentations (e.g. a product page inside an onboarding flow), + * calling `close()` on one will also dismiss the others. + */ close(): void { if (!this.requestId) { return; From 81f15ac821692619159dbc86afeae9074f4aceb4 Mon Sep 17 00:00:00 2001 From: Kevin Date: Fri, 29 May 2026 12:30:37 +0200 Subject: [PATCH 10/77] fix(v6): bound Android interceptor wait; document default() resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../v6/PurchaselyV6Module.kt | 29 +++++++++++++-- .../src/__tests__/v6.integration.test.ts | 36 +++++++++++++++++++ 2 files changed, 63 insertions(+), 2 deletions(-) diff --git a/packages/purchasely/android/src/main/java/com/reactnativepurchasely/v6/PurchaselyV6Module.kt b/packages/purchasely/android/src/main/java/com/reactnativepurchasely/v6/PurchaselyV6Module.kt index df221aad..e744fe07 100644 --- a/packages/purchasely/android/src/main/java/com/reactnativepurchasely/v6/PurchaselyV6Module.kt +++ b/packages/purchasely/android/src/main/java/com/reactnativepurchasely/v6/PurchaselyV6Module.kt @@ -29,6 +29,7 @@ import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull import java.util.UUID import java.util.concurrent.ConcurrentHashMap @@ -53,6 +54,14 @@ object PurchaselyV6Bridge { private const val EVENT_DISMISSED = "PURCHASELY_V6_DISMISSED" private const val EVENT_ACTION_INTERCEPTED = "PURCHASELY_V6_ACTION_INTERCEPTED" + /** + * Upper bound on how long the bridge waits for JS to resolve an intercepted + * action via [completeInterceptor]. If the JS handler never calls back (e.g. + * the event listener was torn down by a bridge reload), we fall back to + * [PLYInterceptResult.NOT_HANDLED] so the SDK is never blocked indefinitely. + */ + private const val INTERCEPTOR_TIMEOUT_MS = 30_000L + /** * Active presentation requests, keyed by the JS-supplied requestId. Lets * `v6Close` / `v6Back` find the right `Prepared` to act on. @@ -68,6 +77,15 @@ object PurchaselyV6Bridge { /** * Build a [PLYPresentationBase.Prepared] from the JS payload. + * + * The JS `isDefault` flag (set by `PresentationBuilder.default()`) is + * intentionally **not** read here: a `default()` request carries no + * `placementId` and no `presentationId`, and the native SDK resolves the + * default presentation (`ply_default`) precisely from that absence — + * `PLYPresentationManager.getPresentation` routes a request with both ids + * null to `apiService.getPresentation(null)`, which substitutes + * `"ply_default"`. An empty builder is therefore the Android equivalent of + * iOS `fetchPresentationWith:nil`, so no `isDefault` branch is required. */ private fun buildPrepared(payload: ReadableMap?): PLYPresentationBase.Prepared { val builder = PLYPresentationBase.builder() @@ -353,8 +371,15 @@ object PurchaselyV6Bridge { sendEvent(reactContext, EVENT_ACTION_INTERCEPTED, payload) CoroutineScope(Dispatchers.Main).launch { - val result = runCatching { deferred.await() } - .getOrDefault(PLYInterceptResult.NOT_HANDLED) + // Bound the suspension: `withTimeoutOrNull` returns null if JS never + // calls back within INTERCEPTOR_TIMEOUT_MS, and `runCatching` guards + // against the deferred being cancelled. In every case we default to + // NOT_HANDLED and drop the pending entry so neither the SDK action + // nor the `complete` lambda is held alive forever. + val result = runCatching { + withTimeoutOrNull(INTERCEPTOR_TIMEOUT_MS) { deferred.await() } + }.getOrNull() ?: PLYInterceptResult.NOT_HANDLED + pendingInterceptors.remove(callbackId) complete(result) } } diff --git a/packages/purchasely/src/__tests__/v6.integration.test.ts b/packages/purchasely/src/__tests__/v6.integration.test.ts index f1057de9..55b0ef05 100644 --- a/packages/purchasely/src/__tests__/v6.integration.test.ts +++ b/packages/purchasely/src/__tests__/v6.integration.test.ts @@ -160,6 +160,42 @@ describe('v6 façade · integration with native bridge', () => { }); }); + describe('PresentationBuilder.default().build()', () => { + // Contract: `default()` carries no placementId/screenId. Both native + // bridges resolve the SDK default presentation from that absence — iOS + // takes its `else if (isDefault)` branch → `fetchPresentationWith:nil`, + // Android builds an empty builder → `ply_default`. The `isDefault` flag + // must therefore reach native with null ids; regressing it silently + // breaks `default()`. + it('sends isDefault:true with null placement + presentation ids (preload)', () => { + const req = PresentationBuilder.default().build(); + req.preload(); + + expect(native.v6Preload).toHaveBeenCalledTimes(1); + const [, payload] = native.v6Preload.mock.calls[0]; + expect(payload.isDefault).toBe(true); + expect(payload.placementId).toBeNull(); + expect(payload.presentationId).toBeNull(); + }); + + it('forwards the same default payload to v6Display', () => { + const req = PresentationBuilder.default().build(); + req.display(); + + expect(native.v6Display).toHaveBeenCalledTimes(1); + const [, payload] = native.v6Display.mock.calls[0]; + expect(payload.isDefault).toBe(true); + expect(payload.placementId).toBeNull(); + expect(payload.presentationId).toBeNull(); + }); + + it('placement()/screen() do not set isDefault', () => { + PresentationBuilder.placement('home').build().preload(); + const [, payload] = native.v6Preload.mock.calls[0]; + expect(payload.isDefault).toBe(false); + }); + }); + describe('PresentationRequest.display() — outcome 5 fields', () => { it('resolves with the full outcome at DISMISS (not at trigger)', async () => { let presentedPayload: any = null; From 8349e1a5183040946af7764a51fee2f8d2e5251d Mon Sep 17 00:00:00 2001 From: Kevin Date: Fri, 29 May 2026 16:22:58 +0200 Subject: [PATCH 11/77] =?UTF-8?q?feat(v6)!:=20remove=20the=20v5=20paywall?= =?UTF-8?q?=20API=20=E2=80=94=20v6=20is=20the=20only=20paywall=20surface?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CLAUDE.md | 67 +- MIGRATION-v6.md | 303 +++++++++ README.md | 139 ++-- example/src/App.tsx | 192 +++--- example/src/Home.tsx | 121 ++-- example/src/Paywall.tsx | 16 +- packages/purchasely/CHANGELOG.md | 52 +- .../purchasely/android/consumer-rules.pro | 1 - .../android/src/main/AndroidManifest.xml | 1 - .../PLYProductActivity.kt | 38 -- .../reactnativepurchasely/PurchaselyModule.kt | 187 ------ packages/purchasely/ios/PurchaselyRN.h | 5 - packages/purchasely/ios/PurchaselyRN.m | 605 ------------------ .../purchasely/src/__tests__/index.test.ts | 205 +----- packages/purchasely/src/index.ts | 226 +------ sdk_public_doc.md | 458 +++++++------ 16 files changed, 819 insertions(+), 1797 deletions(-) create mode 100644 MIGRATION-v6.md delete mode 100644 packages/purchasely/android/src/main/java/com/reactnativepurchasely/PLYProductActivity.kt diff --git a/CLAUDE.md b/CLAUDE.md index 07f5ffa8..709b6bf3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -231,39 +231,56 @@ The following sections provide quick API examples. For comprehensive documentati Refer to the [SDK Public Documentation](../sdk_public_doc.md). -### Initialization +> **v6 paywall API only.** The v5 paywall methods (`start({...})`, +> `presentPresentationForPlacement`, `presentPresentationWithIdentifier`, +> `presentProductWithIdentifier`, `presentPlanWithIdentifier`, +> `fetchPresentation`, `setPaywallActionInterceptorCallback`, `onProcessAction`, +> `setDefaultPresentationResultCallback`, `readyToOpenDeeplink`, …) are +> **removed**. Use the builders below. Full mapping: `MIGRATION-v6.md`. + +### Initialization (v6 builder) ```typescript -import Purchasely, { LogLevels, RunningMode } from 'react-native-purchasely' - -await Purchasely.start({ - apiKey: 'YOUR_API_KEY', - androidStores: ['Google'], // or ['Huawei', 'Amazon'] - storeKit1: false, // iOS: use StoreKit 2 - userId: 'user_id', // optional - logLevel: LogLevels.DEBUG, - runningMode: RunningMode.FULL -}) +import Purchasely from 'react-native-purchasely' + +await Purchasely.builder('YOUR_API_KEY') + .appUserId('user_id') // optional + .runningMode('full') // 'observer' (default) | 'full' + .logLevel('debug') // 'debug' | 'info' | 'warn' | 'error' + .allowDeeplink(true) // replaces readyToOpenDeeplink(true) + .stores(['google']) // Android only: 'google' | 'huawei' | 'amazon' + .storekitVersion('storeKit2') // iOS only: 'storeKit1' | 'storeKit2' + .start() ``` -### Presentation Methods +### Presentation Methods (v6 builders) + +`Purchasely.presentation` is the `PresentationBuilder`. `build()` returns a +`PresentationRequest`; `display()` resolves at dismiss with a 5-field +`PresentationOutcome` (`{ presentation, purchaseResult, plan, closeReason, +error }`). ```typescript -// Fetch presentation data -const presentation = await Purchasely.fetchPresentation({ - placementVendorId: 'ONBOARDING', - contentId: 'content_123' -}) +// Preload a placement (was fetchPresentation) +const request = Purchasely.presentation.placement('ONBOARDING').build() +const presentation = await request.preload() -// Present full-screen paywall -const result = await Purchasely.presentPresentationForPlacement({ - placementVendorId: 'ONBOARDING', - isFullscreen: true -}) +// Present a placement full-screen (was presentPresentationForPlacement) +const outcome = await Purchasely.presentation.placement('ONBOARDING').build().display() -// Present specific product or plan -await Purchasely.presentProductWithIdentifier('product_id') -await Purchasely.presentPlanWithIdentifier('plan_id') +// Present a specific screen (was presentPresentationWithIdentifier) +await Purchasely.presentation.screen('SCREEN_ID').build().display() + +// Present a specific product / plan (was presentProductWithIdentifier / presentPlanWithIdentifier) +await Purchasely.presentation.screen('SCREEN_ID').contentId('CONTENT_ID').build().display() + +// Lifecycle: request.display() (show) / request.close() (hide) / request.back() + +// Action interception (was setPaywallActionInterceptorCallback + onProcessAction) +Purchasely.interceptAction('purchase', async (info, payload) => { + // return 'success' | 'failed' | 'notHandled' + return 'notHandled' +}) ``` ### Event Listening diff --git a/MIGRATION-v6.md b/MIGRATION-v6.md new file mode 100644 index 00000000..a3c833bc --- /dev/null +++ b/MIGRATION-v6.md @@ -0,0 +1,303 @@ +# Migrating to Purchasely React Native SDK v6 + +Purchasely React Native SDK **v6 is paywall-API-only**: the legacy v5 paywall +API has been **REMOVED** (not deprecated). Calling any of the removed methods +will fail to compile (TypeScript) and the methods no longer exist at runtime. + +This guide maps every removed v5 paywall method to its v6 replacement and lists +the methods that are **unchanged**. + +> **Tip — let the AI help you migrate.** The Purchasely AI plugin and the +> `purchasely-integrate`, `purchasely-review` and `purchasely-debug` skills can +> read your integration and rewrite the v5 paywall calls to the v6 builder API +> for you. Point them at the files that call `Purchasely.start`, +> `presentPresentationForPlacement`, `fetchPresentation`, +> `setPaywallActionInterceptorCallback`, etc. + +--- + +## TL;DR + +- The paywall surface is now built around three entry points exposed on the + `Purchasely` default export: + - `Purchasely.builder(apiKey)` — chainable SDK start. + - `Purchasely.presentation` — the `PresentationBuilder` (`.placement(id)`, + `.screen(id)`, `.default()`). + - `Purchasely.interceptAction(kind, handler)` — typed action interception. +- `PresentationBuilder.build()` returns a **`PresentationRequest`** with a + lifecycle (`preload()`, `display(transition?)`, `close()`, `back()`). +- `display()` resolves at **dismiss** with a 5-field `PresentationOutcome` + (`{ presentation, purchaseResult, plan, closeReason, error }`). +- **All CORE methods are UNCHANGED** — see [Unchanged](#whats-unchanged). + +--- + +## Removed v5 paywall API → v6 replacement + +| Removed v5 method | v6 replacement | +|-------------------|----------------| +| `Purchasely.start({ apiKey, androidStores, storeKit1, userId, logLevel, runningMode })` | `Purchasely.builder(apiKey).appUserId(userId).runningMode('full').logLevel('error').stores(['google']).storekitVersion('storeKit2').start()` | +| `Purchasely.startWithAPIKey(apiKey, stores, userId, logLevel, runningMode)` | `Purchasely.builder(apiKey).appUserId(userId).runningMode('full').start()` | +| `Purchasely.fetchPresentation({ placementId })` | `Purchasely.presentation.placement(id).build().preload()` | +| `Purchasely.presentPresentationForPlacement({ placementVendorId })` | `Purchasely.presentation.placement(id).build().display()` | +| `Purchasely.presentPresentationWithIdentifier({ presentationVendorId })` | `Purchasely.presentation.screen(id).build().display()` | +| `Purchasely.presentPresentation({ presentation })` | preload then display the same request: `const req = Purchasely.presentation.placement(id).build(); await req.preload(); await req.display()` | +| `Purchasely.presentProductWithIdentifier(productId, …)` | `Purchasely.presentation.screen(id).contentId(contentId).build().display()` | +| `Purchasely.presentPlanWithIdentifier(planId, …)` | `Purchasely.presentation.screen(id).build().display()` | +| `Purchasely.showPresentation()` / `Purchasely.presentPresentation(...)` | request lifecycle: `request.display()` | +| `Purchasely.hidePresentation()` / `Purchasely.closePresentation()` | request lifecycle: `request.close()` | +| `Purchasely.setPaywallActionInterceptorCallback(cb)` + `Purchasely.onProcessAction(bool)` | `Purchasely.interceptAction(kind, handler)` — handler returns `'success' \| 'failed' \| 'notHandled'` (no more `onProcessAction`) | +| `Purchasely.setDefaultPresentationResultCallback(cb)` / `setDefaultPresentationResultHandler(cb)` | `request.onDismissed(outcome => …)` (or `Purchasely.presentation.placement(id).onDismissed(...).build()`) | +| `Purchasely.readyToOpenDeeplink(true)` | `Purchasely.builder(apiKey).allowDeeplink(true).start()` | + +--- + +## Initialization + +### Before (v5 — removed) + +```typescript +import Purchasely, { LogLevels, RunningMode } from 'react-native-purchasely' + +await Purchasely.start({ + apiKey: 'YOUR_API_KEY', + androidStores: ['Google'], + storeKit1: false, + userId: 'user_id', + logLevel: LogLevels.ERROR, + runningMode: RunningMode.FULL, +}) + +Purchasely.readyToOpenDeeplink(true) +``` + +### After (v6) + +```typescript +import Purchasely from 'react-native-purchasely' + +const configured = await Purchasely.builder('YOUR_API_KEY') + .appUserId('user_id') // optional, defaults to anonymous + .runningMode('full') // 'observer' (default) | 'full' + .logLevel('error') // 'debug' | 'info' | 'warn' | 'error' + .allowDeeplink(true) // replaces readyToOpenDeeplink(true) + .allowCampaigns(true) // automatic campaigns + .stores(['google']) // Android only: 'google' | 'huawei' | 'amazon' + .storekitVersion('storeKit2')// iOS only: 'storeKit1' | 'storeKit2' + .start() +``` + +> **Default running mode changed.** In v6 the default `runningMode` is +> `'observer'` — the host app keeps control of the purchase flow unless it opts +> into `'full'`. Pass `.runningMode('full')` to keep the previous v5 default +> behaviour where Purchasely owns the purchase flow. + +--- + +## Displaying a paywall + +### Before (v5 — removed) + +```typescript +const result = await Purchasely.presentPresentationForPlacement({ + placementVendorId: 'ONBOARDING', + contentId: 'my_content_id', + isFullscreen: true, +}) + +switch (result.result) { + case ProductResult.PRODUCT_RESULT_PURCHASED: + case ProductResult.PRODUCT_RESULT_RESTORED: + console.log('Purchased', result.plan?.name) + break + case ProductResult.PRODUCT_RESULT_CANCELLED: + break +} +``` + +### After (v6) + +`display()` resolves at **dismiss** with a `PresentationOutcome`: + +```typescript +const outcome = await Purchasely.presentation + .placement('ONBOARDING') + .contentId('my_content_id') + .build() + .display() + +// outcome: { presentation, purchaseResult, plan, closeReason, error } +if (outcome.error) { + console.error(outcome.error.message) +} else if (outcome.purchaseResult === 'purchased' || outcome.purchaseResult === 'restored') { + console.log('Purchased', outcome.plan?.name) +} else { + console.log('Dismissed', outcome.closeReason) // 'button' | 'backSystem' | 'programmatic' +} +``` + +`purchaseResult` is now a string union (`'purchased' | 'cancelled' | 'restored'`) +instead of the `ProductResult` ordinal enum. + +### Targeting a specific screen / product / plan + +```typescript +// Specific presentation by screen id (was presentPresentationWithIdentifier) +await Purchasely.presentation.screen('SCREEN_ID').build().display() + +// Specific product (was presentProductWithIdentifier) +await Purchasely.presentation.screen('SCREEN_ID').contentId('CONTENT_ID').build().display() + +// Specific plan (was presentPlanWithIdentifier) +await Purchasely.presentation.screen('SCREEN_ID').build().display() +``` + +--- + +## Pre-fetching (preload) + +### Before (v5 — removed) + +```typescript +const presentation = await Purchasely.fetchPresentation({ placementId: 'ONBOARDING' }) +const result = await Purchasely.presentPresentation({ presentation }) +``` + +### After (v6) + +```typescript +const request = Purchasely.presentation.placement('ONBOARDING').build() +const presentation = await request.preload() // resolves when the screen is loaded +// later, when ready to show it: +const outcome = await request.display() +``` + +--- + +## Presentation lifecycle (show / hide / close) + +The imperative `showPresentation` / `hidePresentation` / `closePresentation` +methods are replaced by the request lifecycle: + +```typescript +const request = Purchasely.presentation.placement('ONBOARDING').build() + +request.display() // show +request.close() // hide / close +request.back() // navigate back inside a multi-step (Flow) presentation +``` + +> `request.close()` currently dismisses **all** displayed presentations (the +> native SDK does not yet expose a per-request close). If you stack +> presentations, closing one will dismiss the others. + +--- + +## Action interceptor + +`setPaywallActionInterceptorCallback` + `onProcessAction` are replaced by +`Purchasely.interceptAction(kind, handler)`. Register **one handler per action +kind**; the handler returns `'success' | 'failed' | 'notHandled'` instead of +calling `onProcessAction(true/false)`. + +### Before (v5 — removed) + +```typescript +Purchasely.setPaywallActionInterceptorCallback((result) => { + if (result.action === PLYPaywallAction.PURCHASE) { + MyPurchaseSystem.purchase(result.parameters.plan.productId) + Purchasely.onProcessAction(false) + } else { + Purchasely.onProcessAction(true) + } +}) +``` + +### After (v6) + +```typescript +import { Linking } from 'react-native' + +Purchasely.interceptAction('purchase', async (info, payload) => { + if (payload?.kind === 'purchase') { + const ok = await MyPurchaseSystem.purchase(payload.plan.productId) + return ok ? 'success' : 'failed' + } + return 'notHandled' +}) + +Purchasely.interceptAction('navigate', async (info, payload) => { + if (payload?.kind === 'navigate') { + Linking.openURL(payload.url) + return 'success' + } + return 'notHandled' +}) + +// Cleanup +Purchasely.removeActionInterceptor('purchase') +Purchasely.removeAllActionInterceptors() +``` + +Known action kinds: `close`, `closeAll`, `login`, `navigate`, `purchase`, +`restore`, `openPresentation`, `openPlacement`, `promoCode`, `webCheckout`. + +--- + +## Deeplinks & default result handler + +```typescript +// Allow deeplinks (replaces readyToOpenDeeplink(true)) — set at start: +await Purchasely.builder('YOUR_API_KEY').allowDeeplink(true).start() + +// Default result handler (replaces setDefaultPresentationResultCallback): +Purchasely.presentation + .default() + .onDismissed((outcome) => { + console.log('Deeplink paywall dismissed', outcome.purchaseResult, outcome.closeReason) + }) + .build() + .display() + +// isDeeplinkHandled is UNCHANGED: +const handled = await Purchasely.isDeeplinkHandled('app://ply/presentations/') +``` + +--- + +## What's UNCHANGED + +All **core** SDK methods are unchanged in name, signature, and behaviour. Only +the v5 *paywall* surface was removed. The following keep working exactly as in +v5: + +- **User**: `userLogin`, `userLogout`, `getAnonymousUserId`, `isAnonymous`, + `synchronize`. +- **Products**: `allProducts`, `productWithIdentifier`, `planWithIdentifier`, + `purchaseWithPlanVendorId`, `signPromotionalOffer`, `isEligibleForIntroOffer`, + `setDynamicOffering`, `getDynamicOfferings`, `removeDynamicOffering`, + `clearDynamicOfferings`. +- **Subscriptions**: `userSubscriptions`, `userSubscriptionsHistory`, + `restoreAllProducts`, `silentRestoreAllProducts`, + `userDidConsumeSubscriptionContent`, **`presentSubscriptions`**. +- **Attributes**: `setUserAttributeWith{String,Number,Boolean,Date,StringArray,NumberArray,BooleanArray}`, + `incrementUserAttribute`, `decrementUserAttribute`, `userAttributes`, + `userAttribute`, `clearUserAttribute`, `clearUserAttributes`, + `clearBuiltInAttributes`, `setAttribute`. +- **Listeners**: `addEventListener` / `removeEventListener`, + `addPurchasedListener` / `removePurchasedListener`, + `addUserAttributeSetListener` / `removeUserAttributeSetListener`, + `addUserAttributeRemovedListener` / `removeUserAttributeRemovedListener`. +- **Client (BYOS) presentations**: **`clientPresentationDisplayed`**, + **`clientPresentationClosed`** — unchanged. +- **Misc**: `setLogLevel`, `setLanguage`, `setThemeMode`, `setDebugMode`, + `isDeeplinkHandled`, `revokeDataProcessingConsent`, `getConstants`, `close`. +- **Embedded component**: `PLYPresentationView` — unchanged. + +--- + +## Need a hand? + +Use the Purchasely AI plugin / skills (`purchasely-integrate`, +`purchasely-review`, `purchasely-debug`) to scan your project and apply this +migration automatically. diff --git a/README.md b/README.md index 5f926cf0..588d65d1 100644 --- a/README.md +++ b/README.md @@ -10,31 +10,32 @@ npm install react-native-purchasely ## 🔧 Setup +> **v6** — the SDK is initialized and paywalls are displayed with the chainable +> builder API. The legacy v5 paywall API (`start({...})`, `startWithAPIKey`, +> `presentPresentationForPlacement`, `fetchPresentation`, +> `setPaywallActionInterceptorCallback`, …) has been **removed**. See +> [`MIGRATION-v6.md`](./MIGRATION-v6.md) for the full old→new mapping. + Add the following code in the root of your project (typically `App.tsx` in a React Native project): ```ts -import Purchasely, { LogLevels, RunningMode } from 'react-native-purchasely' - -Purchasely.startWithAPIKey( - 'afa96c76-1d8e-4e3c-a48f-204a3cd93a15', - ['Google'], // List of stores for Android, accepted values: Google, Huawei, and Amazon - null, // Your user ID - LogLevels.DEBUG, // Log level, should be warning or error in production - RunningMode.FULL // Running mode -).then( - (configured) => { - if (!configured) { - console.log('Purchasely SDK not properly initialized') - return - } - - console.log('Purchasely SDK is initialized') - setupPurchasely() - }, - (error) => { - console.log('Purchasely SDK initialization error', error) - } -) +import Purchasely from 'react-native-purchasely' + +const configured = await Purchasely.builder('afa96c76-1d8e-4e3c-a48f-204a3cd93a15') + .stores(['google']) // Android stores: 'google' | 'huawei' | 'amazon' + .appUserId(null) // your user ID, or null for anonymous + .logLevel('debug') // 'warn' or 'error' in production + .runningMode('full') // 'observer' (default) | 'full' + .allowDeeplink(true) + .storekitVersion('storeKit2') // iOS only + .start() + +if (!configured) { + console.log('Purchasely SDK not properly initialized') +} else { + console.log('Purchasely SDK is initialized') + setupPurchasely() +} ``` ## 🎬 Usage @@ -42,28 +43,25 @@ Purchasely.startWithAPIKey( ### 1️⃣ Full Screen Paywall ```ts -import Purchasely, { - PLYPresentationType, - ProductResult, -} from 'react-native-purchasely' +import Purchasely from 'react-native-purchasely' try { - const result = await Purchasely.presentPresentationForPlacement({ - placementVendorId: 'composer', - loadingBackgroundColor: '#FFFFFFFF', - }) - - console.log('Result is ' + result.result) - - switch (result.result) { - case ProductResult.PRODUCT_RESULT_PURCHASED: - case ProductResult.PRODUCT_RESULT_RESTORED: - if (result.plan != null) { - console.log('User purchased ' + result.plan.name) - } - break - case ProductResult.PRODUCT_RESULT_CANCELLED: - break + // display() resolves at dismiss with a PresentationOutcome + const outcome = await Purchasely.presentation + .placement('composer') + .backgroundColor('#FFFFFFFF') + .build() + .display() + + if (outcome.error) { + console.error(outcome.error.message) + } else if ( + outcome.purchaseResult === 'purchased' || + outcome.purchaseResult === 'restored' + ) { + console.log('User purchased ' + outcome.plan?.name) + } else { + console.log('Dismissed: ' + outcome.closeReason) } } catch (e) { console.error(e) @@ -72,71 +70,29 @@ try { ### 2️⃣ Nested View Paywall +The embedded `PLYPresentationView` component is part of the **core** API and is +unchanged in v6. Pass a `placementId` directly — no manual pre-fetch step is +required. + ```ts import { Text, View } from 'react-native'; import { NativeStackScreenProps } from '@react-navigation/native-stack'; import { Header } from 'react-native/Libraries/NewAppScreen'; import { Section } from './Section.tsx'; -import Purchasely, { - PLYPresentationView, - PresentPresentationResult, - ProductResult, - PurchaselyPresentation, -} from 'react-native-purchasely'; -import { useEffect, useState } from 'react'; +import { PLYPresentationView, PresentPresentationResult } from 'react-native-purchasely'; export const PaywallScreen: React.FC> = ({ navigation }) => { - const [purchaselyPresentation, setPurchaselyPresentation] = useState(); - - useEffect(() => { - fetchPresentation(); - }, []); - - const fetchPresentation = async () => { - try { - setPurchaselyPresentation( - await Purchasely.fetchPresentation({ - placementId: 'ONBOARDING', - contentId: null, - }) - ); - } catch (e) { - console.error(e); - } - }; - const callback = (result: PresentPresentationResult) => { - console.log('### Paywall closed'); - console.log('### Result is ' + result.result); - switch (result.result) { - case ProductResult.PRODUCT_RESULT_PURCHASED: - case ProductResult.PRODUCT_RESULT_RESTORED: - if (result.plan != null) { - console.log('User purchased ' + result.plan.name); - } - break; - case ProductResult.PRODUCT_RESULT_CANCELLED: - console.log('User cancelled'); - break; - } + console.log('### Paywall closed, result is ' + result.result); navigation.goBack(); }; - if (purchaselyPresentation == null) { - return ( - - Loading ... - - ); - } - return (
callback(res)} /> @@ -153,6 +109,9 @@ export const PaywallScreen: React.FC> = ({ navigatio A complete documentation is available on our website: [Purchasely Docs](https://docs.purchasely.com/quick-start/sdk-installation/react-native-sdk). +Migrating from v5? See [`MIGRATION-v6.md`](./MIGRATION-v6.md) for the complete +old→new mapping of every removed v5 paywall method. + ## 🛠️ Developer Guide ### 1️⃣ Clone the Repository diff --git a/example/src/App.tsx b/example/src/App.tsx index 4b95b246..d490f654 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -4,15 +4,11 @@ import { createNativeStackNavigator } from '@react-navigation/native-stack' import { HomeScreen } from './Home.tsx' import Purchasely, { DynamicOffering, - LogLevels, + InterceptResult, PLYDataProcessingLegalBasis, PLYDataProcessingPurpose, - PLYPaywallAction, PresentationBuilder, - PurchaselyBuilder, PurchaselyUserAttribute, - RunningMode, - interceptAction, removeAllActionInterceptors, } from 'react-native-purchasely' import { PaywallScreen } from './Paywall.tsx' @@ -23,20 +19,23 @@ function App(): React.JSX.Element { async function setupPurchasely() { var configured = false try { - // ApiKey and StoreKit1 attributes are mandatory - configured = await Purchasely.start({ - apiKey: 'fcb39be4-2ba4-4db7-bde3-2a5a1e20745d', - storeKit1: false, // false to use StoreKit 2 and true to use StoreKit 1 - logLevel: LogLevels.DEBUG, // to force log level for debug - userId: 'test-user-id', // if you know your user id - runningMode: RunningMode.FULL, // to set mode manually - }) + // v6 chained builder — the only supported way to start the SDK. + // `allowDeeplink(true)` replaces the legacy `readyToOpenDeeplink`. + configured = await Purchasely.builder( + 'fcb39be4-2ba4-4db7-bde3-2a5a1e20745d' + ) + .appUserId('test-user-id') // if you know your user id + .runningMode('full') // to set mode manually + .logLevel('debug') // to force log level for debug + .allowDeeplink(true) // safe to launch purchase flow from deeplinks + .allowCampaigns(true) + .storekitVersion('storeKit2') // iOS: 'storeKit2' or 'storeKit1' + .stores(['google']) // Android stores + .start() } catch (e) { console.log('Purchasely SDK configuration error:', e) } - // fetchPresentation() - if (!configured) { console.error('Purchasely SDK initialization failed.') } else { @@ -46,9 +45,6 @@ function App(): React.JSX.Element { // logout the user Purchasely.userLogout() - //indicate to sdk it is safe to launch purchase flow - Purchasely.readyToOpenDeeplink(true) - //force your language Purchasely.setLanguage('en') @@ -230,58 +226,47 @@ function App(): React.JSX.Element { const offeringsEmpty: DynamicOffering[] = await Purchasely.getDynamicOfferings() console.log('Dynamic offerings:', offeringsEmpty) - // Set paywall action interceptor callback - Purchasely.setPaywallActionInterceptorCallback((result) => { - console.log('Received action from paywall') - console.log('Action:', result.action) - console.log('Parameters:', result.parameters) - console.log('Info:', result.info) - - - switch (result.action) { - case PLYPaywallAction.NAVIGATE: - console.log( - 'User wants to navigate to website ' + - result.parameters.title + - ' ' + - result.parameters.url - ) - Purchasely.onProcessAction(true) - break - case PLYPaywallAction.LOGIN: - console.log('User wants to login') - //Present your own screen for user to log in - Purchasely.hidePresentation() - // Call this method to display Purchaely paywall - // Purchasely.showPresentation() - // Call this method to update Purchasely Paywall - // Purchasely.onProcessAction(true); - break - case PLYPaywallAction.PURCHASE: - console.log('User wants to purchase') - Purchasely.onProcessAction(true) - //Purchasely.hidePresentation(); - - /** - * If you want to intercept it, hide presentation and display your screen - * then call onProcessAction() to continue or stop purchasely purchase action like this - * - * First hide presentation to display your own screen - * Purchasely.hidePresentation() - * - * Call this method to display Purchasely paywall - * Purchasely.showPresentation() - * - * Call this method to update Purchasely Paywall - * Purchasely.onProcessAction(true|false); // true to continue, false to stop - * - * Purchasely.closePresentation(); //when you want to close the paywall (after purchase for example) - * - **/ - break - default: - Purchasely.onProcessAction(true) + // Set paywall action interceptors (v6). Each handler is typed by the + // action kind and must return 'success' | 'failed' | 'notHandled'. + // Returning 'notHandled' lets the SDK perform its default behavior; + // 'success' tells the SDK the host app fully handled the action. + + // Navigate action — open the requested website yourself if needed. + Purchasely.interceptAction('navigate', async (info, payload): Promise => { + console.log('User wants to navigate', info, payload) + if (payload?.kind === 'navigate') { + console.log( + 'User wants to navigate to website ' + + payload.title + + ' ' + + payload.url + ) } + // Let the SDK open the URL with its default behavior. + return 'notHandled' + }) + + // Login action — present your own login screen here. Returning + // 'success' tells the SDK login is handled by the host app. + Purchasely.interceptAction('login', async (info): Promise => { + console.log('User wants to login', info) + // Present your own screen for the user to log in, then return + // 'success' once done — or 'notHandled' to keep the SDK default. + return 'notHandled' + }) + + // Purchase action — intercept the purchase if you handle it yourself, + // otherwise return 'notHandled' to let Purchasely run the purchase. + Purchasely.interceptAction('purchase', async (info, payload): Promise => { + console.log('User wants to purchase', info, payload) + /** + * To intercept the purchase, present your own screen and run your + * own transaction, then return 'success' or 'failed'. + * Returning 'notHandled' lets Purchasely run its default purchase + * flow. To close the paywall programmatically afterwards, hold a + * reference to the PresentationRequest and call `request.close()`. + **/ + return 'notHandled' }) // Set events listener @@ -301,58 +286,32 @@ function App(): React.JSX.Element { }) } - const fetchPresentation = async () => { + // Preload a placement so its paywall is ready before the user reaches it. + // The v6 `preload()` resolves once the screen is loaded (no UI shown yet). + const preloadOnboarding = async () => { try { - await Purchasely.fetchPresentation({ - placementId: 'ONBOARDING', - contentId: null, - }) + const presentation = await PresentationBuilder.placement( + 'ONBOARDING' + ) + .contentId(null) + .build() + .preload() + console.info('[v6] preloaded', presentation.screenId) } catch (e) { console.error(e) } } // ------------------------------------------------------------------------- - // v6 builder demo. The v6 API is the cross-platform replacement for the - // legacy `Purchasely.start(...)` / `fetchPresentation` flow. It mirrors - // the Android-style chained builder. + // v6 presentation demo. Builds an ONBOARDING placement request, wires up + // every lifecycle callback, then displays it. `display()` resolves at + // DISMISS with a 5-field `PresentationOutcome`. // - // To opt in, uncomment the `setupPurchaselyV6()` call inside the + // To opt in, uncomment the `presentOnboardingV6()` call inside the // `useEffect` below. // ------------------------------------------------------------------------- - async function setupPurchaselyV6() { - try { - // Chained start — equivalent to the legacy `Purchasely.start({...})` - // but uses Android-style typed strings + chain options. - await PurchaselyBuilder.apiKey( - 'fcb39be4-2ba4-4db7-bde3-2a5a1e20745d' - ) - .appUserId('test-user-id') - .runningMode('full') - .logLevel('debug') - .allowDeeplink(true) - .allowCampaigns(true) - .storekitVersion('storeKit2') - .stores(['google']) - .start() - } catch (e) { - console.error('[v6] start failed:', e) - return - } - - // Register at least one interceptor — purchase action — per the v6 - // bridge contract. Handlers must return 'success' | 'failed' | - // 'notHandled'. - interceptAction('purchase', async (info, payload) => { - console.info('[v6] purchase intercepted', info, payload) - // Returning 'notHandled' lets the SDK perform its default - // purchase flow. Switch to 'success' / 'failed' if you handle - // the transaction yourself. - return 'notHandled' - }) - - // Build, present and react to lifecycle callbacks. `display()` - // resolves at DISMISS with a 5-field `PresentationOutcome`. + async function presentOnboardingV6() { + // Build, present and react to lifecycle callbacks. const request = PresentationBuilder.placement('ONBOARDING') .contentId('content_123') .onLoaded((presentation) => { @@ -382,17 +341,16 @@ function App(): React.JSX.Element { const outcome = await request.display({ type: 'fullScreen' }) console.info('[v6] display() resolved with outcome', outcome) - // Equivalent helper to detach every interceptor previously registered. + // Detach every interceptor previously registered. removeAllActionInterceptors() } useEffect(() => { setupPurchasely() - fetchPresentation() - // Uncomment to exercise the v6 builder pipeline alongside the legacy - // v5 example flow above. The two are mutually exclusive at runtime: - // calling start() twice will return the cached initialization. - // setupPurchaselyV6() + preloadOnboarding() + // Uncomment to display the ONBOARDING paywall through the v6 pipeline + // once the SDK has been started by `setupPurchasely()` above. + // presentOnboardingV6() }, []) return ( diff --git a/example/src/Home.tsx b/example/src/Home.tsx index eaa8eaa5..5d352452 100644 --- a/example/src/Home.tsx +++ b/example/src/Home.tsx @@ -11,29 +11,21 @@ import { NativeStackScreenProps } from '@react-navigation/native-stack' import { Colors } from 'react-native/Libraries/NewAppScreen' import Purchasely, { PLYPresentationType, - ProductResult, - PurchaselyPresentation, + PresentationBuilder, + PresentationRequest, } from 'react-native-purchasely' import DeviceInfo from 'react-native-device-info' -import { useEffect, useRef } from 'react' +import { useRef } from 'react' export const HomeScreen: React.FC> = ({ navigation, }) => { const isDarkMode = useColorScheme() === 'dark' - const cachedPresentation = useRef(null) + // Holds the most recently displayed v6 request so it can be closed or + // navigated back programmatically (replaces the v5 show/hide/close calls). + const currentRequest = useRef(null) - useEffect(() => { - Purchasely.fetchPresentation({ - placementId: 'nested', - contentId: null, - }) - .then((p) => { - cachedPresentation.current = p - }) - .catch(console.error) - }, []) const backgroundStyle = { backgroundColor: isDarkMode ? Colors.darker : Colors.lighter, } @@ -41,21 +33,13 @@ export const HomeScreen: React.FC> = ({ // Boutons d'exemples const buttons = [ { title: 'Display Presentation', onPress: () => onPressPresentation() }, - { title: 'Fetch Presentation', onPress: () => onPressFetch() }, + { title: 'Preload Presentation', onPress: () => onPressPreload() }, { title: 'Display Nested View', onPress: () => onPressNestedView() }, - { - title: 'Show Presentation', - onPress: () => onPressShowPresentation(), - }, - { - title: 'Hide Presentation', - onPress: () => onPressHidePresentation(), - }, { title: 'Close Presentation', onPress: () => onPressClosePresentation(), }, - { title: 'Continue Action', onPress: () => onPressContinueAction() }, + { title: 'Back', onPress: () => onPressBack() }, { title: 'Purchase', onPress: () => onPressPurchase() }, { title: 'Purchase With Promotional Offer', @@ -76,23 +60,25 @@ export const HomeScreen: React.FC> = ({ const onPressPresentation = async () => { try { - const result = await Purchasely.presentPresentationForPlacement({ - placementVendorId: 'premium_support', - // isFullscreen: true, - loadingBackgroundColor: '#FFFFFFFF', - }) + // v6: build a request for the placement, then display it. + // `display()` resolves at DISMISS with a PresentationOutcome. + const request = PresentationBuilder.placement('premium_support') + .backgroundColor('#FFFFFFFF') + .build() + currentRequest.current = request - console.log('Result is ' + result.result) + const outcome = await request.display({ type: 'fullScreen' }) - switch (result.result) { - case ProductResult.PRODUCT_RESULT_PURCHASED: - case ProductResult.PRODUCT_RESULT_RESTORED: - if (result.plan != null) { - console.log('User purchased ' + result.plan.name) - } + console.log('Purchase result is ' + outcome.purchaseResult) + switch (outcome.purchaseResult) { + case 'purchased': + case 'restored': + if (outcome.plan != null) { + console.log('User purchased ' + outcome.plan.name) + } break - case ProductResult.PRODUCT_RESULT_CANCELLED: + case 'cancelled': break } } catch (e) { @@ -100,18 +86,22 @@ export const HomeScreen: React.FC> = ({ } } - const onPressFetch = async () => { + const onPressPreload = async () => { try { - const presentation = await Purchasely.fetchPresentation({ - placementId: 'FLOW', - contentId: null, - }) + // v6: preload a placement without showing any UI yet. Resolves once + // the screen is loaded. Inspect the resolved Presentation to decide + // whether to display a Purchasely paywall or your own screen. + const presentation = await PresentationBuilder.placement('FLOW') + .contentId(null) + .build() + .preload() console.log(presentation.placementId) console.log('Type = ' + presentation.type) console.log( 'Plans = ' + JSON.stringify(presentation.plans, null, 2) ) + if (presentation.type === PLYPresentationType.DEACTIVATED) { // No paywall to display return @@ -126,23 +116,25 @@ export const HomeScreen: React.FC> = ({ return } - //Display Purchasely paywall - const result = await Purchasely.presentPresentation({ - presentation: presentation, - }) + // Display the preloaded Purchasely paywall. + const request = PresentationBuilder.placement('FLOW') + .contentId(null) + .build() + currentRequest.current = request + + const outcome = await request.display({ type: 'fullScreen' }) console.log('---- Paywall Closed ----') - console.log('Result is ' + result.result) + console.log('Purchase result is ' + outcome.purchaseResult) - switch (result.result) { - case ProductResult.PRODUCT_RESULT_PURCHASED: - case ProductResult.PRODUCT_RESULT_RESTORED: - if (result.plan != null) { - console.log('User purchased ' + result.plan.name) + switch (outcome.purchaseResult) { + case 'purchased': + case 'restored': + if (outcome.plan != null) { + console.log('User purchased ' + outcome.plan.name) } - break - case ProductResult.PRODUCT_RESULT_CANCELLED: + case 'cancelled': console.log('User cancelled') break } @@ -152,27 +144,20 @@ export const HomeScreen: React.FC> = ({ } const onPressNestedView = () => { + // The embedded PLYPresentationView is driven by a placement id in v6. navigation.navigate('Paywall', { - presentation: cachedPresentation.current, + placementId: 'nested', }) } - const onPressShowPresentation = () => { - Purchasely.showPresentation() - } - - const onPressHidePresentation = () => { - Purchasely.hidePresentation() - } - const onPressClosePresentation = () => { - Purchasely.closePresentation() + // v6: close the currently displayed request programmatically. + currentRequest.current?.close() } - const onPressContinueAction = () => { - //Call this method to continue Purchasely action - Purchasely.showPresentation() - Purchasely.onProcessAction(true) + const onPressBack = () => { + // v6: navigate back inside a multi-step (Flow) presentation. + currentRequest.current?.back() } const onPressPurchase = async () => { diff --git a/example/src/Paywall.tsx b/example/src/Paywall.tsx index 09668f32..60fc3fe6 100644 --- a/example/src/Paywall.tsx +++ b/example/src/Paywall.tsx @@ -5,19 +5,18 @@ import { PLYPresentationView, PresentPresentationResult, ProductResult, - PurchaselyPresentation, } from 'react-native-purchasely' export const PaywallScreen: React.FC> = ({ navigation, route, }) => { - const purchaselyPresentation: PurchaselyPresentation | null = - (route.params as any)?.presentation ?? null + // v6: the embedded PLYPresentationView is driven by a placement id. + const placementId: string | null = + (route.params as any)?.placementId ?? null console.log('### Paywall screen') - console.log('presentation', purchaselyPresentation) - console.log('presentation height : ', purchaselyPresentation?.height) + console.log('placementId', placementId) const callback = (result: PresentPresentationResult) => { console.log('### Paywall closed') @@ -37,10 +36,10 @@ export const PaywallScreen: React.FC> = ({ navigation.goBack() } - if (purchaselyPresentation === null) { + if (placementId === null) { return ( - No presentation (not fetched yet) + No placement provided ) } @@ -61,9 +60,8 @@ export const PaywallScreen: React.FC> = ({