Skip to content

feat: migrate to Purchasely 6.0 native SDK (6.0.0-rc.1)#56

Closed
chouaibMo wants to merge 12 commits into
mainfrom
release/6.0.0-rc.1
Closed

feat: migrate to Purchasely 6.0 native SDK (6.0.0-rc.1)#56
chouaibMo wants to merge 12 commits into
mainfrom
release/6.0.0-rc.1

Conversation

@chouaibMo

Copy link
Copy Markdown
Contributor

Summary

Migrates the Purchasely Cordova plugin to the Purchasely 6.0 native SDKs (iOS Purchasely 6.0.0-rc.2, Android io.purchasely:core 6.0.0-rc.2), mirroring the Flutter migration in Purchasely-Flutter#120. Wrapper version → 6.0.0-rc.1.

This is a migration, not a rewrite — the breaking surfaces are SDK start, presentation display mode, and the action-interceptor result; the rest stays source-compatible (with deprecated aliases for renamed methods).

What changed

JavaScript (purchasely/www/Purchasely.js)

  • start(apiKey, …)start(options, success, error) config object
  • RunningMode values are now name strings (observer/full) — the native iOS/Android enums use different raw values, so the bridge maps by name; default is now observer. paywallObserver kept as a deprecated alias
  • synchronize(success, error) now reports completion (was fire-and-forget)
  • Deeplink: allowDeeplink / handleDeeplink (+ new allowCampaigns); old readyToOpenDeeplink / isDeeplinkHandled kept as deprecated aliases
  • setDefaultPresentationDismissHandler (old …ResultHandler kept as alias)
  • Presentation: isFullscreen bool → display-mode string (bool still normalized); add backPresentation(); removed presentSubscriptions, presentProduct/PlanWithIdentifier, show/hidePresentation (no v6 primitive)
  • Interceptor: onProcessAction(InterceptResult) replaces the bool (legacy bool mapped); PaywallAction kept + PresentationAction alias
  • New constants: InterceptResult, PresentationType, CloseReason, TransitionType, DimensionType, Store, StorekitVersion
  • Unit tests (__tests__/Purchasely.test.js) updated in lockstep — 80 tests pass

iOS (purchasely/src/ios/CDVPurchasely.m, Objective-C)

  • Migrated to the v6 @objc API (verified against the framework's generated Purchasely-Swift.h) — no Swift rewrite/shim needed: apiKey builder → startWithInitialized:; PLYPresentationBuilder.forPlacementId:/forScreenId:buildpreloadWithCompletion: / displayFrom:transitionType:; interceptAction:handler:PLYInterceptResult; setDefaultPresentationDismissHandler:; allowDeeplink:/handleDeeplink:/allowCampaigns:; closeAllScreens; backPresentation
  • PLYPresentationOutcome serialization (result int, closeReason, error, presentation)
  • Fixed v6 type changes: PLYAttribute is now an enum (was used as a pointer); declared PLYEventDelegate/PLYUserAttributeDelegate conformance on the implementing categories; guarded user-attribute callbacks (v6 delegate setters are _Nonnull)

Android (purchasely/src/android/PurchaselyPlugin.kt, Kotlin)

  • Migrated to io.purchasely:core 6.0.0-rc.2: options-dict start; PLYPresentationBase.builder().build().preload()loaded.display(activity, transition){ outcome }; interceptAction(…)PLYInterceptResult; dismiss handler; deeplink renames; synchronize success/error; CoroutineScope for suspend queries
  • Removed PLYProductActivity / PLYSubscriptionsActivity (+ layouts/themes, plugin.xml source-file/AndroidManifest entries) — dropped in native 6.0

CI / E2E

  • ci.yml / publish.yml aligned with Flutter (action bumps, workflow_dispatch, concurrency); prerelease tags publish under the npm next dist-tag, stable under latest
  • New e2e-android.yml / e2e-ios.yml + an Appium + WebdriverIO project (purchasely/example/e2e/) mirroring the Flutter E2E_TEST_INDEX suite: a deterministic WEBVIEW-context bridge suite (hard gate) and a best-effort dismiss suite; workflow_dispatch + nightly + scoped PRs

Docs

  • MIGRATION-v6.md (new), VERSIONS.md row 6.0.0-rc.1 → 6.0.0-rc.2 / 6.0.0-rc.2, READMEs, CLAUDE.md, example app updated to the 6.0 API

Verification

  • iOS example builds against Purchasely 6.0.0-rc.2 (BUILD SUCCEEDED; only a benign setThemeMode deprecation)
  • Android example builds app-debug.apk against io.purchasely:core 6.0.0-rc.2, and runs on an API 34 emulator — SDK initializes (sdkVersion=6.0.0-rc.2), events flow (PRESENTATION_LOADED/VIEWED, PURCHASE_TAPPED), no crash
  • 80/80 JS unit tests pass; version-consistency check passes

Notes for reviewers

  • RC: mark the GitHub release pre-release; npm publish auto-selects the next dist-tag for X.Y.Z-* tags
  • E2E: the deterministic bridge suite hard-gates; device suites are best-effort (dispatch/nightly), same policy as Flutter. To make device suites pass in CI, a placement (default ONBOARDING) must exist for com.purchasely.demo on the example API key's backend — they need one real-runner shakeout
  • Known limitations (documented as TODO(v6-verify)): drawer/popin transition dimensions default (JS sends only a display-mode string); a single interceptor completion is stashed per round-trip

See VERSIONS.md and the native release notes: iOS 6.0.0-rc.2, Android 6.0.0-rc.2.

🤖 Generated with Claude Code

chouaibMo and others added 9 commits July 2, 2026 16:22
- plugin.xml (both): plugin version 6.0.0-rc.1; iOS pod Purchasely 6.0.0-rc.2;
  Android io.purchasely:core + google-play 6.0.0-rc.2
- package.json / package-lock (main + example): 6.0.0-rc.1
- www/Purchasely.js cordovaSdkVersion fallback 6.0.0-rc.1
- VERSIONS.md: add 6.0.0-rc.1 -> 6.0.0-rc.2 / 6.0.0-rc.2 row

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- start(options, success, error): single config object instead of positional args
- RunningMode values are now name strings ('observer'/'full'); paywallObserver kept
  as a deprecated alias -> 'observer' (native iOS/Android enum raw values differ)
- synchronize(success, error) now reports completion
- deeplink renames: allowDeeplink / handleDeeplink (+ new allowCampaigns); old
  readyToOpenDeeplink / isDeeplinkHandled kept as deprecated JS aliases
- setDefaultPresentationDismissHandler (setDefaultPresentationResultHandler alias)
- presentation display mode string replaces isFullscreen bool (bool normalized);
  add backPresentation(); remove presentSubscriptions, presentProduct/PlanWithIdentifier,
  show/hidePresentation (no v6 primitive)
- interceptor: onProcessAction accepts InterceptResult string (legacy bool mapped);
  PaywallAction kept, add PresentationAction alias
- new constants: InterceptResult, PresentationType, CloseReason, TransitionType,
  DimensionType, Store, StorekitVersion
- update __tests__/Purchasely.test.js in lockstep (80 tests pass)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- start(options) config object; RunningMode/Store constants
- allowDeeplink / handleDeeplink; setDefaultPresentationDismissHandler
- interceptor uses PresentationAction + InterceptResult; fix closePaywall bug
- display-mode strings; add backPresentation; wire openDeeplink button
- remove presentSubscriptions / show / hide presentation buttons+handlers

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- ci.yml: actions/checkout@v7, setup-node@v5, setup-java@v5, cache@v5;
  Node 24; add workflow_dispatch + concurrency (cancel-in-progress)
- publish.yml: checkout@v7, setup-node@v5; publish prerelease tags (X.Y.Z-*)
  under npm 'next' dist-tag, stable under 'latest'

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- MIGRATION-v6.md: start(options), RunningMode (strings/observer default),
  synchronize completion, display-mode strings, removed methods, InterceptResult
  interceptor, deeplink renames, dismiss handler rename, new constants, @next install
- README (root + purchasely): 6.0 start(options) + display-mode examples, migration pointer
  (also fixes stale startWithAPIKey in purchasely/README)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- PurchaselyPlugin.kt migrated to io.purchasely:core 6.0.0-rc.2 (mirrors Flutter v6):
  start() parses one options dict; runningMode mapped by name (Observer/Full);
  PLYPresentationBase.builder().build().preload() -> loaded.display(activity, transition){outcome};
  fetchPresentation stashes Loaded for later display; closePresentation -> closeAllScreens();
  add backPresentation(); interceptAction() for all kinds -> single callback + onProcessAction(string);
  setDefaultPresentationDismissHandler; allowDeeplink/handleDeeplink/allowCampaigns;
  synchronize reports success/error; CoroutineScope for suspend queries; isEligibleToOffer rename
- remove PLYProductActivity/PLYSubscriptionsActivity (+ layouts/themes) — dropped in native 6.0;
  drop their plugin.xml source-file + AndroidManifest activity entries and stale example config
- build-extras.gradle: Java 11 / kotlin jvmTarget 11 (core 6.0 ships Java 11 bytecode)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- CDVPurchasely.m migrated to the v6 @objc API (verified against generated
  Purchasely-Swift.h; no Swift shim needed): start() parses one options dict ->
  PurchaselyBuilder chain -> startWithInitialized:; PLYPresentationBuilder
  forScreenId:/forPlacementId: -> build -> preloadWithCompletion: / display;
  displayFrom:transitionType:(PLYDisplayMode); closeAllScreens; backPresentation;
  interceptAction:handler: for all kinds -> single callback + onProcessAction(string
  -> PLYInterceptResult); setDefaultPresentationDismissHandler; allowDeeplink/
  handleDeeplink/allowCampaigns; synchronize success/error; PLYPresentationOutcome
  serialization (result int, closeReason, error, presentation)
- remove presentSubscriptions / presentPlan|ProductWithIdentifier / hide|showPresentation
- fix v6 type changes: PLYAttribute is an enum (was pointer) in setAttribute:;
  declare PLYEventDelegate/PLYUserAttributeDelegate conformance on the categories that
  implement them; guard user-attribute callbacks on attributeCommand (v6 delegate
  setters are _Nonnull, no native unregister)

Verified: iOS example builds against Purchasely 6.0.0-rc.2 (BUILD SUCCEEDED).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- revert build-extras.gradle Java-11 override: cordova-android 15 already compiles at
  Java 17 (>= 11), which consumes io.purchasely:core 6.0.0-rc.2 (Java 11 bytecode); the
  kotlin tasks.withType override failed to resolve in the cordova gradle context
- example devDeps/lock refreshed by platform add (cordova-android 15, cordova-ios 8.1);
  plugin version fields stay 6.0.0-rc.1

Verified: Android example builds app-debug.apk against io.purchasely:core 6.0.0-rc.2.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Mirrors the Flutter integration_test suite (E2E_TEST_INDEX) adapted to the Cordova
imperative API, driving the real Purchasely 6.0 native SDK.

- purchasely/example/e2e: WebdriverIO project (Appium uiautomator2 + xcuitest drivers)
  - WEBVIEW-context bridge suite (hard gate): getAnonymousUserId, allProducts,
    fetchPresentationForPlacement, synchronize completion, user-attribute round-trip
  - dismiss suite (best-effort): present placement + programmatic close -> closeReason
  - helpers/driver.js (context switch + bridge invoker), runner scripts with 3x retry
    and Flutter-style gating (hard bridge, best-effort dismiss), README
- .github/workflows/e2e-android.yml (emulator-runner, API 34) + e2e-ios.yml (simulator)
  on workflow_dispatch + nightly + scoped PRs; upload ci-logs artifacts
- CLAUDE.md: document e2e workflows + rc npm dist-tag

Note: device suites need the E2E placement to exist for com.purchasely.demo on the
example API key's backend; the deterministic bridge suite is the hard gate.

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

greptile-apps Bot commented Jul 2, 2026

Copy link
Copy Markdown

Greptile Summary

This PR migrates the Purchasely Cordova plugin to the v6 native SDKs (iOS 6.0.0-rc.2, Android 6.0.0-rc.2), updating the start API to an options object, replacing the boolean isFullscreen with a string display-mode, migrating presentations from Activity-based to builder+preload+display, replacing the single-bool interceptor with a typed PLYInterceptResult, and adding E2E infrastructure.

  • JavaScript layer (Purchasely.js): new start(options) contract, RunningMode by name, synchronize now reports completion, deeplink renames with deprecated aliases, new InterceptResult/CloseReason/TransitionType/DimensionType/Store/StorekitVersion constants, 80 unit tests pass.
  • Android (PurchaselyPlugin.kt): v6 builder+preload()+display() flow replaces the removed PLYProductActivity/PLYSubscriptionsActivity; ConcurrentHashMap tracks fetched presentations for fetchPresentationpresentPresentation; CoroutineScope wraps all suspend calls — but a newly introduced map entry is never removed after presentPresentation displays it, leaking native presentation objects.
  • iOS (CDVPurchasely.m): PLYPresentationBuilder + preloadWithCompletion: + displayFrom:transitionType: pattern, per-action interceptors, PLYPresentationOutcome serialization; fetchPresentation preload callback has no branch for the nil, nil (deactivated placement) case, and the presentPresentation guard on controller == nil may be incompatible with how v6 sets the controller property.

Confidence Score: 3/5

This is a large migration targeting a pre-release SDK (rc.2). The JS layer and tests are clean; the native bridges have two correctness gaps that will affect real user flows.

The Android presentPresentation path never removes the preloaded presentation from loadedPresentations, so every fetch-then-display cycle leaks a native object — the old v5 code had explicit cleanup that was dropped in the rewrite. On iOS, fetchPresentation's preloadWithCompletion: callback has no branch for the deactivated-placement case (both args nil), leaving the JS callback permanently pending. A third question about whether the v6 iOS SDK populates controller before display (which the presentPresentation guard depends on) could make the entire fetch→present flow fail silently. Together these make the preloaded-presentation path unverified end-to-end in CI.

purchasely/src/android/PurchaselyPlugin.kt (presentPresentation cleanup), purchasely/src/ios/CDVPurchasely.m (fetchPresentation completion and presentPresentation controller guard)

Important Files Changed

Filename Overview
purchasely/src/android/PurchaselyPlugin.kt Full v6 migration: builder+preload+display replaces Activity-based flow, typed interceptors, CoroutineScope for suspend calls — but loadedPresentations entries are never removed after presentPresentation displays them, leaking native objects.
purchasely/src/ios/CDVPurchasely.m v6 iOS migration to PLYPresentationBuilder+preload+display, per-action interceptors, PLYPresentationOutcome serialization — fetchPresentation has a silent hang on nil/nil completion, and the presentPresentation controller nil-guard may be broken for preloaded v6 presentations.
purchasely/www/Purchasely.js New options-based start(), RunningMode by name, synchronize with callbacks, deeplink renames with backward-compat aliases, new v6 constants — all cleanly implemented with 80/80 tests.
purchasely/plugin.xml Removed PLYProductActivity/PLYSubscriptionsActivity entries; bumped native deps to 6.0.0-rc.2 on both platforms; clean removal of deleted layout and theme files.
purchasely/src/ios/CDVPurchasely.h Header updated for v6: PLYPresentation instead of controller-based, PLYInterceptResult completion block, new backPresentation/allowDeeplink/allowCampaigns methods.
purchasely/tests/Purchasely.test.js 80 tests covering new v6 constants, renamed methods, deprecated aliases, and changed start signature — comprehensive coverage of the JS layer changes.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant JS as JavaScript
    participant Bridge as Cordova Bridge
    participant Native as Native SDK (iOS/Android)

    JS->>Bridge: start(options)
    Bridge->>Native: Purchasely.Builder(options).build().start()
    Native-->>Bridge: error? / success
    Bridge-->>JS: success(true) / error(msg)

    JS->>Bridge: fetchPresentation(placementId, contentId)
    Bridge->>Native: PLYPresentationBase.builder().placementId().build().preload()
    Native-->>Bridge: Loaded presentation (stored in map/array)
    Bridge-->>JS: "success(presentationDict{id, type, plans...})"

    JS->>Bridge: presentPresentation(presentationDict, displayMode)
    Bridge->>Native: loaded.display(activity, transition)
    Note over Bridge,Native: Android: map entry NOT removed
    Native-->>Bridge: PLYPresentationOutcome (on dismiss)
    Bridge-->>JS: "success({result, plan, closeReason, presentation})"

    JS->>Bridge: setPaywallActionInterceptor()
    Bridge->>Native: interceptAction(kind, handler) x10 kinds
    Native-->>Bridge: PLYInterceptorInfo + action payload
    Bridge-->>JS: "success({action, info, parameters})"

    JS->>Bridge: "onProcessAction(success|failed|notHandled)"
    Bridge->>Native: completion(PLYInterceptResult)
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant JS as JavaScript
    participant Bridge as Cordova Bridge
    participant Native as Native SDK (iOS/Android)

    JS->>Bridge: start(options)
    Bridge->>Native: Purchasely.Builder(options).build().start()
    Native-->>Bridge: error? / success
    Bridge-->>JS: success(true) / error(msg)

    JS->>Bridge: fetchPresentation(placementId, contentId)
    Bridge->>Native: PLYPresentationBase.builder().placementId().build().preload()
    Native-->>Bridge: Loaded presentation (stored in map/array)
    Bridge-->>JS: "success(presentationDict{id, type, plans...})"

    JS->>Bridge: presentPresentation(presentationDict, displayMode)
    Bridge->>Native: loaded.display(activity, transition)
    Note over Bridge,Native: Android: map entry NOT removed
    Native-->>Bridge: PLYPresentationOutcome (on dismiss)
    Bridge-->>JS: "success({result, plan, closeReason, presentation})"

    JS->>Bridge: setPaywallActionInterceptor()
    Bridge->>Native: interceptAction(kind, handler) x10 kinds
    Native-->>Bridge: PLYInterceptorInfo + action payload
    Bridge-->>JS: "success({action, info, parameters})"

    JS->>Bridge: "onProcessAction(success|failed|notHandled)"
    Bridge->>Native: completion(PLYInterceptResult)
Loading

Fix All in Claude Code

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

---

### Issue 1 of 4
purchasely/src/android/PurchaselyPlugin.kt:576-601
**Missing `loadedPresentations` cleanup after display**

After `presentPresentation` retrieves and displays a preloaded presentation, the entry is never removed from `loadedPresentations`. Every successful `fetchPresentation` call adds a `PLYPresentationBase.Loaded` object to the map, but nothing removes it once `presentPresentation` is done. The old v5 code explicitly called `presentationsLoaded.removeAll { it.id == presentation.id && it.placementId == presentation.placementId }` after display. With high-frequency calls (e.g., onboarding flows that fetch and display repeatedly), native presentation objects accumulate indefinitely, leaking memory.

Add `loadedPresentations.remove(handle)` after successfully retrieving the loaded presentation in `presentPresentation`.

### Issue 2 of 4
purchasely/src/android/PurchaselyPlugin.kt:585-590
The preloaded presentation should be removed from `loadedPresentations` once it has been retrieved for display, mirroring the iOS cleanup (`[self.presentationsLoaded removeObjectAtIndex:index]`) and the v5 `presentationsLoaded.removeAll` pattern. Without removal, repeated `fetchPresentation`+`presentPresentation` calls grow the map indefinitely.

```suggestion
        val handle = if (presentationMap.has("id")) presentationMap.optString("id") else null
        val loaded = handle?.let { loadedPresentations.remove(it) }
        if (loaded == null) {
            callbackContext.error("presentation cannot be found")
            return
        }
```

### Issue 3 of 4
purchasely/src/ios/CDVPurchasely.m:1003-1012
**`fetchPresentation` command hangs when completion returns `nil, nil`**

The `preloadWithCompletion:` callback handles `error != nil` (calls `failureFor:`) and `presentation != nil` (calls `successFor:`), but has no branch for the case where both are `nil`. In v6, a deactivated placement or a race condition where the SDK cannot build a presentation can produce a `nil, nil` completion. When that happens, neither `failureFor:` nor `successFor:` is ever sent, leaving the Cordova command permanently pending — the JS success/error callbacks are never invoked.

Add an `else` branch after the `else if (presentation != nil)` block that calls `failureFor:command resultString:@"Presentation unavailable"` (or similar).

### Issue 4 of 4
purchasely/src/ios/CDVPurchasely.m:1031-1035
**`presentationLoaded.controller` may always be `nil` before v6 display**

The guard `presentationLoaded.controller == nil` was valid in v5 where the controller was set during `preloadWithCompletion`. In v6, the `PLYPresentation` returned from `preloadWithCompletion` may not populate `controller` until `displayFrom:transitionType:` is called. If the v6 SDK lazily sets `controller` on first display, this guard will always fail with "Presentation not loaded" for every pre-fetched presentation, making `fetchPresentation``presentPresentation` completely non-functional on iOS. Could you confirm whether `id<PLYPresentation>.controller` is non-nil after v6 preload (before display)?

Reviews (1): Last reviewed commit: "test(e2e): add Appium + WebdriverIO devi..." | Re-trigger Greptile

Comment on lines 576 to 601
private fun presentPresentation(presentationMap: JSONObject?,
isFullScreen: Boolean,
displayMode: String?,
loadingBackgroundColor: String?,
callbackContext: CallbackContext) {
if (presentationMap == null) {
callbackContext.error("presentation cannot be null")
return
}

val presentation = presentationsLoaded.lastOrNull {
it.id == presentationMap.getString("id")
&& it.placementId == presentationMap.getString("placementId")
}
if(presentation == null) {
val handle = if (presentationMap.has("id")) presentationMap.optString("id") else null
val loaded = handle?.let { loadedPresentations[it] }
if (loaded == null) {
callbackContext.error("presentation cannot be found")
return
}

purchaseCallback = callbackContext
displayedPresentation = loaded

cordova.activity.let { activity ->
if (presentation.flowId != null) {
presentation.display(activity) { result, plan ->
sendPurchaseResult(result, plan)
}
} else {
// Open legacy Activity for now if not a flow
val intent = PLYProductActivity.newIntent(activity, PLYPresentationProperties(), isFullScreen, loadingBackgroundColor).apply {
putExtra("presentation", presentation)
}
activity.startActivity(intent)
// TODO(v6-verify): loadingBackgroundColor cannot be applied to an already-preloaded
// presentation in v6 (background/progress colors are builder options set before preload).
cordova.activity?.runOnUiThread {
loaded.display(cordova.activity, transitionForDisplayMode(displayMode)) { outcome ->
sendPurchaseResult(outcome)
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Missing loadedPresentations cleanup after display

After presentPresentation retrieves and displays a preloaded presentation, the entry is never removed from loadedPresentations. Every successful fetchPresentation call adds a PLYPresentationBase.Loaded object to the map, but nothing removes it once presentPresentation is done. The old v5 code explicitly called presentationsLoaded.removeAll { it.id == presentation.id && it.placementId == presentation.placementId } after display. With high-frequency calls (e.g., onboarding flows that fetch and display repeatedly), native presentation objects accumulate indefinitely, leaking memory.

Add loadedPresentations.remove(handle) after successfully retrieving the loaded presentation in presentPresentation.

Prompt To Fix With AI
This is a comment left during a code review.
Path: purchasely/src/android/PurchaselyPlugin.kt
Line: 576-601

Comment:
**Missing `loadedPresentations` cleanup after display**

After `presentPresentation` retrieves and displays a preloaded presentation, the entry is never removed from `loadedPresentations`. Every successful `fetchPresentation` call adds a `PLYPresentationBase.Loaded` object to the map, but nothing removes it once `presentPresentation` is done. The old v5 code explicitly called `presentationsLoaded.removeAll { it.id == presentation.id && it.placementId == presentation.placementId }` after display. With high-frequency calls (e.g., onboarding flows that fetch and display repeatedly), native presentation objects accumulate indefinitely, leaking memory.

Add `loadedPresentations.remove(handle)` after successfully retrieving the loaded presentation in `presentPresentation`.

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

Fix in Claude Code

Comment on lines +585 to 590
val handle = if (presentationMap.has("id")) presentationMap.optString("id") else null
val loaded = handle?.let { loadedPresentations[it] }
if (loaded == null) {
callbackContext.error("presentation cannot be found")
return
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 The preloaded presentation should be removed from loadedPresentations once it has been retrieved for display, mirroring the iOS cleanup ([self.presentationsLoaded removeObjectAtIndex:index]) and the v5 presentationsLoaded.removeAll pattern. Without removal, repeated fetchPresentation+presentPresentation calls grow the map indefinitely.

Suggested change
val handle = if (presentationMap.has("id")) presentationMap.optString("id") else null
val loaded = handle?.let { loadedPresentations[it] }
if (loaded == null) {
callbackContext.error("presentation cannot be found")
return
}
val handle = if (presentationMap.has("id")) presentationMap.optString("id") else null
val loaded = handle?.let { loadedPresentations.remove(it) }
if (loaded == null) {
callbackContext.error("presentation cannot be found")
return
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: purchasely/src/android/PurchaselyPlugin.kt
Line: 585-590

Comment:
The preloaded presentation should be removed from `loadedPresentations` once it has been retrieved for display, mirroring the iOS cleanup (`[self.presentationsLoaded removeObjectAtIndex:index]`) and the v5 `presentationsLoaded.removeAll` pattern. Without removal, repeated `fetchPresentation`+`presentPresentation` calls grow the map indefinitely.

```suggestion
        val handle = if (presentationMap.has("id")) presentationMap.optString("id") else null
        val loaded = handle?.let { loadedPresentations.remove(it) }
        if (loaded == null) {
            callbackContext.error("presentation cannot be found")
            return
        }
```

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

Fix in Claude Code

Comment on lines +1003 to +1012
id<PLYPresentationRequest> request = [builder build];
[request preloadWithCompletion:^(id<PLYPresentation> _Nullable presentation, NSError * _Nullable error) {
if (error != nil) {
[self failureFor:command resultString: error.localizedDescription];
} else if (presentation != nil) {
[self.presentationsLoaded addObject:presentation];
[self successFor:command resultDict:[self resultDictionaryForFetchPresentation:presentation]];
}
}];
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 fetchPresentation command hangs when completion returns nil, nil

The preloadWithCompletion: callback handles error != nil (calls failureFor:) and presentation != nil (calls successFor:), but has no branch for the case where both are nil. In v6, a deactivated placement or a race condition where the SDK cannot build a presentation can produce a nil, nil completion. When that happens, neither failureFor: nor successFor: is ever sent, leaving the Cordova command permanently pending — the JS success/error callbacks are never invoked.

Add an else branch after the else if (presentation != nil) block that calls failureFor:command resultString:@"Presentation unavailable" (or similar).

Prompt To Fix With AI
This is a comment left during a code review.
Path: purchasely/src/ios/CDVPurchasely.m
Line: 1003-1012

Comment:
**`fetchPresentation` command hangs when completion returns `nil, nil`**

The `preloadWithCompletion:` callback handles `error != nil` (calls `failureFor:`) and `presentation != nil` (calls `successFor:`), but has no branch for the case where both are `nil`. In v6, a deactivated placement or a race condition where the SDK cannot build a presentation can produce a `nil, nil` completion. When that happens, neither `failureFor:` nor `successFor:` is ever sent, leaving the Cordova command permanently pending — the JS success/error callbacks are never invoked.

Add an `else` branch after the `else if (presentation != nil)` block that calls `failureFor:command resultString:@"Presentation unavailable"` (or similar).

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

Fix in Claude Code

Comment on lines +1031 to 1035
if (presentationLoaded == nil || presentationLoaded.controller == nil) {
[self failureFor:command resultString: @"Presentation not loaded"];
return;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 presentationLoaded.controller may always be nil before v6 display

The guard presentationLoaded.controller == nil was valid in v5 where the controller was set during preloadWithCompletion. In v6, the PLYPresentation returned from preloadWithCompletion may not populate controller until displayFrom:transitionType: is called. If the v6 SDK lazily sets controller on first display, this guard will always fail with "Presentation not loaded" for every pre-fetched presentation, making fetchPresentationpresentPresentation completely non-functional on iOS. Could you confirm whether id<PLYPresentation>.controller is non-nil after v6 preload (before display)? In the v6 iOS SDK, does a PLYPresentation returned from preloadWithCompletion: have a non-nil controller property before displayFrom:transitionType: is called?

Prompt To Fix With AI
This is a comment left during a code review.
Path: purchasely/src/ios/CDVPurchasely.m
Line: 1031-1035

Comment:
**`presentationLoaded.controller` may always be `nil` before v6 display**

The guard `presentationLoaded.controller == nil` was valid in v5 where the controller was set during `preloadWithCompletion`. In v6, the `PLYPresentation` returned from `preloadWithCompletion` may not populate `controller` until `displayFrom:transitionType:` is called. If the v6 SDK lazily sets `controller` on first display, this guard will always fail with "Presentation not loaded" for every pre-fetched presentation, making `fetchPresentation``presentPresentation` completely non-functional on iOS. Could you confirm whether `id<PLYPresentation>.controller` is non-nil after v6 preload (before display)? In the v6 iOS SDK, does a `PLYPresentation` returned from `preloadWithCompletion:` have a non-nil `controller` property before `displayFrom:transitionType:` is called?

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

Fix in Claude Code

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Migrates the Purchasely Cordova plugin bridge to Purchasely 6.0 native SDKs (iOS/Android RC), aligning the JavaScript API with the new v6 start/presentation/interceptor contracts and updating CI + docs accordingly.

Changes:

  • Updated JS bridge API: start(options), display-mode strings (with legacy boolean normalization), interceptor result enum, deeplink/dismiss-handler renames + deprecated aliases.
  • Migrated iOS (Objective-C) and Android (Kotlin) native implementations to Purchasely v6 presentation + interceptor APIs; removed obsolete Android Activities/resources.
  • Added Appium + WebdriverIO E2E scaffolding and updated CI/publish workflows and documentation for the 6.0 RC line.

Reviewed changes

Copilot reviewed 43 out of 53 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
VERSIONS.md Adds v6.0.0-rc.1 → native v6.0.0-rc.2 version mapping.
README.md Updates top-level usage docs for start(options) and display-mode transition type.
purchasely/www/Purchasely.js Updates JS bridge API for v6 (start/options, transitions, interceptor result, deeplink/dismiss handler renames).
purchasely/src/ios/CDVPurchasely+UserAttributes.m Guards user-attribute callbacks when listener is not registered.
purchasely/src/ios/CDVPurchasely+UserAttributes.h Declares v6 PLYUserAttributeDelegate conformance on the implementing category.
purchasely/src/ios/CDVPurchasely+Events.h Declares v6 PLYEventDelegate conformance on the implementing category.
purchasely/src/ios/CDVPurchasely.m Migrates iOS bridge to Purchasely v6 start/presentation/interceptor APIs and outcome serialization.
purchasely/src/ios/CDVPurchasely.h Updates iOS plugin surface/types for v6 (presentation tracking, interceptor completion signature).
purchasely/src/android/theme_purchasely_fullscreen.xml Removes obsolete fullscreen theme (dropped with removed Activities).
purchasely/src/android/theme_purchasely_fullscreen_v23.xml Removes obsolete v23 fullscreen theme.
purchasely/src/android/theme_purchasely_fullscreen_v23_light_status_bar.xml Removes obsolete v23 light-status-bar fullscreen theme.
purchasely/src/android/PurchaselyPlugin.kt Migrates Android bridge to Purchasely v6 APIs (start options, presentations, synchronize callbacks, interceptors, coroutine-based queries).
purchasely/src/android/PLYSubscriptionsActivity.java Removes deprecated subscriptions Activity (no longer in v6).
purchasely/src/android/PLYProductActivity.kt Removes deprecated product Activity (no longer in v6).
purchasely/src/android/activity_ply_subscriptions_activity.xml Removes obsolete subscriptions Activity layout.
purchasely/src/android/activity_ply_product_activity.xml Removes obsolete product Activity layout.
purchasely/README.md Updates package-level docs for v6 usage and display-mode argument.
purchasely/plugin.xml Bumps plugin + native dependency versions; removes obsolete Android Activity/resource entries.
purchasely/package.json Bumps wrapper version to 6.0.0-rc.1.
purchasely/package-lock.json Bumps lock/package version to 6.0.0-rc.1.
purchasely/example/www/js/index.js Updates example app to v6 JS API and new interceptor result + deeplink/dismiss handlers.
purchasely/example/www/index.html Updates example UI to remove deprecated actions and add back navigation.
purchasely/example/package.json Updates Cordova platform versions used by the example.
purchasely/example/package-lock.json Updates example lockfile to new Cordova platform versions and plugin versions.
purchasely/example/e2e/wdio.shared.conf.js Adds shared WDIO config for E2E suites.
purchasely/example/e2e/wdio.ios.conf.js Adds iOS simulator WDIO config.
purchasely/example/e2e/wdio.android.conf.js Adds Android emulator WDIO config.
purchasely/example/e2e/tools/ci_run_e2e.sh Adds Android E2E runner script with hard/best-effort gating.
purchasely/example/e2e/tools/ci_run_e2e_ios.sh Adds iOS E2E runner script with hard/best-effort gating.
purchasely/example/e2e/specs/dismiss.e2e.js Adds best-effort dismiss/outcome E2E coverage.
purchasely/example/e2e/specs/bridge.e2e.js Adds hard-gated deterministic WEBVIEW bridge E2E coverage.
purchasely/example/e2e/README.md Documents E2E suites, gating policy, and local/CI requirements.
purchasely/example/e2e/package.json Adds Appium + WebdriverIO dev dependencies for E2E.
purchasely/example/e2e/helpers/driver.js Adds helper utilities for context switching and bridge invocation in E2E.
purchasely/example/e2e/.gitignore Ignores E2E node_modules/logs/lockfile.
purchasely/example/config.xml Removes obsolete Android edit-config for removed Activity theme override.
purchasely/tests/Purchasely.test.js Updates unit tests for v6 JS API + new exported constants and renamed methods.
purchasely-google/plugin.xml Bumps Google Play companion plugin version + dependency to v6 RC.
purchasely-google/package.json Bumps Google Play companion plugin version to 6.0.0-rc.1.
MIGRATION-v6.md Adds a dedicated migration guide for 5.x → 6.0 JS API changes.
CLAUDE.md Updates CI/publish notes including prerelease next dist-tag behavior and E2E workflow references.
.idea/markdown.xml Adds IDE markdown preview settings (project metadata).
.idea/inspectionProfiles/Project_Default.xml Adds IDE inspection profile (project metadata).
.idea/copilot.data.migration.edit.xml Adds IDE Copilot migration state (project metadata).
.idea/copilot.data.migration.ask2agent.xml Adds IDE Copilot migration state (project metadata).
.idea/copilot.data.migration.agent.xml Adds IDE Copilot migration state (project metadata).
.github/workflows/publish.yml Updates publish workflow, including prerelease next dist-tag publishing logic.
.github/workflows/e2e-ios.yml Adds iOS E2E workflow for real-backend Appium/WDIO runs.
.github/workflows/e2e-android.yml Adds Android E2E workflow for real-backend Appium/WDIO runs.
.github/workflows/ci.yml Updates CI workflow triggers, concurrency, action versions, and Node version.
Files not reviewed (8)
  • .idea/caches/deviceStreaming.xml: Generated file
  • .idea/copilot.data.migration.agent.xml: Generated file
  • .idea/copilot.data.migration.ask2agent.xml: Generated file
  • .idea/copilot.data.migration.edit.xml: Generated file
  • .idea/inspectionProfiles/Project_Default.xml: Generated file
  • .idea/markdown.xml: Generated file
  • purchasely/example/package-lock.json: Generated file
  • purchasely/package-lock.json: Generated file

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

Comment on lines +9 to +13
function normalizeDisplayMode(mode) {
if (mode === true) return 'fullScreen';
if (mode === false) return 'modal';
return mode || 'fullScreen';
}
Comment on lines +49 to +59
var fn = window.Purchasely[method];
if (typeof fn !== 'function') {
return finish({ ok: false, error: 'no such method: ' + method });
}
fn.apply(
window.Purchasely,
args.concat([
function (value) { finish({ ok: true, value: value }); },
function (error) { finish({ ok: false, error: String(error) }); },
])
);
Comment on lines +585 to +593
val handle = if (presentationMap.has("id")) presentationMap.optString("id") else null
val loaded = handle?.let { loadedPresentations[it] }
if (loaded == null) {
callbackContext.error("presentation cannot be found")
return
}

purchaseCallback = callbackContext
displayedPresentation = loaded
chouaibMo and others added 3 commits July 6, 2026 10:45
Add the v6 per-action interceptor surface across all three layers, matching
the native SDK (which intercepts per action) and the RN/Flutter contract:

- JS: interceptAction(kind, handler) returning an InterceptResult, plus
  removeActionInterceptor(kind) / removeAllActionInterceptors(). The old
  setPaywallActionInterceptor + onProcessAction are kept as a deprecated
  shim over the new machinery.
- iOS/Android: registerActionInterceptor / unregisterActionInterceptor /
  completeActionInterceptor, keyed by a per-invocation callbackId. This
  fixes the previous single-stashed-completion limitation — concurrent
  intercepts now resolve independently.
- Update example app, MIGRATION-v6.md, and unit tests (85/85 pass).

Cleanup (removes 4023 lines of noise):
- Remove 6 committed .idea/ files and purchasely-google/.DS_Store.
- Revert purchasely/yarn.lock to main's stub (spurious yarn install bloat;
  the project uses package-lock.json).
- Add a root .gitignore so these can't return.

Verified: Android :app:compileDebugKotlin and iOS xcodebuild both succeed
against Purchasely 6.0.0-rc.2.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The JS CloseReason enum listed iOS's values {none, button,
interactive_dismiss, programmatic}, but native Android PLYCloseReason
emits back_system (button/back_system/programmatic), which was absent
from the enum — Android apps could not match it, and the two bridges
diverged.

Align with the native wire contract shared with the Flutter plugin's
PLYCloseReason:
- JS enum -> {button, backSystem:'back_system', programmatic}
- iOS resultDictionaryForOutcome: Button->button,
  InteractiveDismiss->back_system (matches the SDK's own convention,
  Purchasely-Swift.h: "interactiveDismiss stringifies to back_system"),
  Programmatic->programmatic, None-> omit key (mirrors Flutter's null
  closeReason on iOS)
- Android unchanged (already emits the canonical values, verified
  against core-6.0.0-rc.2)
- Update unit test (85/85 pass) and MIGRATION-v6.md

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Flutter PR #120 removed the v5 compatibility aliases outright; mirror
that clean break in the Cordova bridge instead of keeping shims.

Removed from the JS API:
- readyToOpenDeeplink            -> allowDeeplink
- isDeeplinkHandled              -> handleDeeplink
- setDefaultPresentationResultHandler -> setDefaultPresentationDismissHandler
- setPaywallActionInterceptor + onProcessAction -> interceptAction(kind, handler)
- RunningMode.paywallObserver    -> observer
- PaywallAction constant         -> PresentationAction (now the canonical name)

Also drop the legacy boolean interceptor-result normalization (handlers
now return an InterceptResult). Native bridges are unchanged — the aliases
were JS-only and delegated to the retained native actions. Updated the
example app, unit tests (77 pass), and MIGRATION-v6.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@chouaibMo chouaibMo closed this Jul 6, 2026
@chouaibMo chouaibMo deleted the release/6.0.0-rc.1 branch July 6, 2026 09:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants