diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 44ce031..0a2b1f3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,6 +4,11 @@ on: pull_request: branches: [main, master] workflow_call: + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true jobs: unit-tests: @@ -12,12 +17,12 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v5 with: - node-version: "20" + node-version: "24" - name: Install dependencies working-directory: purchasely @@ -34,12 +39,12 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v5 with: - node-version: "20" + node-version: "24" cache: "npm" cache-dependency-path: purchasely/example/package-lock.json @@ -56,7 +61,7 @@ jobs: # (legacy spec repo, ~1.5 GB) since CocoaPods CDN handles spec lookups # on demand and the cache save was the real bottleneck. - name: Cache CocoaPods - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: ~/Library/Caches/CocoaPods key: ${{ runner.os }}-cocoapods-${{ hashFiles('purchasely/plugin.xml') }} @@ -81,17 +86,17 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v5 with: - node-version: "20" + node-version: "24" cache: "npm" cache-dependency-path: purchasely/example/package-lock.json - name: Setup Java - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: distribution: "temurin" java-version: "17" @@ -126,7 +131,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Check version consistency run: | diff --git a/.github/workflows/e2e-android.yml b/.github/workflows/e2e-android.yml new file mode 100644 index 0000000..05040d2 --- /dev/null +++ b/.github/workflows/e2e-android.yml @@ -0,0 +1,115 @@ +name: E2E Android + +# End-to-end JS <-> Android tests against the REAL Purchasely backend, run on a real +# Android emulator via Appium + WebdriverIO. NOT part of the PR-gating ci.yml (they need +# an emulator, real network and the uiautomator2 driver) — they run on demand + nightly. +# +# Requirements to go green: +# * the placement PURCHASELY_E2E_PLACEMENT (default ONBOARDING) must exist for the +# example app id com.purchasely.demo on the backend the example API key points to. +# * the deterministic `bridge` suite hard-gates; the `dismiss` suite is best-effort. +on: + pull_request: + branches: [main] + paths: + - "purchasely/www/**" + - "purchasely/src/android/**" + - "purchasely/example/**" + - ".github/workflows/e2e-android.yml" + workflow_dispatch: + schedule: + - cron: "0 3 * * *" + +concurrency: + group: e2e-android-${{ github.ref }} + cancel-in-progress: true + +jobs: + e2e-android: + name: E2E Android (real backend) + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - name: Checkout repository + uses: actions/checkout@v7 + + - name: Enable KVM (hardware acceleration for the emulator) + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \ + | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - name: Setup Node.js + uses: actions/setup-node@v5 + with: + node-version: "24" + + - name: Setup Java + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: "17" + cache: gradle + + - name: Setup Android SDK + uses: android-actions/setup-android@v3 + + - name: Install Cordova CLI + run: npm install -g cordova + + - name: Build debug APK + working-directory: purchasely/example + run: | + npm install + cordova platform remove android || true + cordova platform add android + cordova plugin add ../ --link + cordova plugin add ../../purchasely-google/ --link + cordova build android + + - name: Install E2E deps (Appium + WebdriverIO) + working-directory: purchasely/example/e2e + run: npm install + + - name: AVD cache + uses: actions/cache@v5 + id: avd-cache + with: + path: | + ~/.android/avd/* + ~/.android/adb* + key: avd-34-google_apis-x86_64-pixel_6 + + - name: Create AVD + snapshot for caching + if: steps.avd-cache.outputs.cache-hit != 'true' + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: 34 + target: google_apis + arch: x86_64 + profile: pixel_6 + force-avd-creation: false + emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none + disable-animations: true + script: echo "Generated AVD snapshot for caching." + + - name: Run E2E suite on emulator + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: 34 + target: google_apis + arch: x86_64 + profile: pixel_6 + force-avd-creation: false + emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none + disable-animations: true + script: bash purchasely/example/e2e/tools/ci_run_e2e.sh emulator-5554 + + - name: Upload E2E logs + if: always() + uses: actions/upload-artifact@v7 + with: + name: e2e-android-logs + path: purchasely/example/e2e/ci-logs/ + retention-days: 7 diff --git a/.github/workflows/e2e-ios.yml b/.github/workflows/e2e-ios.yml new file mode 100644 index 0000000..f63e59b --- /dev/null +++ b/.github/workflows/e2e-ios.yml @@ -0,0 +1,79 @@ +name: E2E iOS + +# End-to-end JS <-> iOS tests against the REAL Purchasely backend, run on an iOS +# simulator via Appium (XCUITest) + WebdriverIO. NOT part of the PR-gating ci.yml. +# Runs on demand + nightly. See e2e-android.yml for the placement requirement. +on: + pull_request: + branches: [main] + paths: + - "purchasely/www/**" + - "purchasely/src/ios/**" + - "purchasely/example/**" + - ".github/workflows/e2e-ios.yml" + workflow_dispatch: + schedule: + - cron: "0 4 * * *" + +concurrency: + group: e2e-ios-${{ github.ref }} + cancel-in-progress: true + +jobs: + e2e-ios: + name: E2E iOS (real backend) + runs-on: macos-15 + timeout-minutes: 60 + steps: + - name: Checkout repository + uses: actions/checkout@v7 + + - name: Setup Node.js + uses: actions/setup-node@v5 + with: + node-version: "24" + + - name: Install Cordova CLI + run: npm install -g cordova + + - name: Cache CocoaPods + uses: actions/cache@v5 + with: + path: ~/Library/Caches/CocoaPods + key: ${{ runner.os }}-cocoapods-${{ hashFiles('purchasely/plugin.xml') }} + restore-keys: | + ${{ runner.os }}-cocoapods- + + - name: Build simulator app + working-directory: purchasely/example + run: | + npm install + cordova platform remove ios || true + cordova platform add ios + cordova plugin add ../ --link + cordova build ios --emulator + + - name: Install E2E deps (Appium + WebdriverIO) + working-directory: purchasely/example/e2e + run: npm install + + - name: Boot iOS simulator + id: sim + run: | + UDID=$(xcrun simctl list devices available | grep -E "iPhone 16|iPhone 15" | head -1 | grep -oE "[0-9A-F-]{36}") + echo "Booting simulator $UDID" + xcrun simctl boot "$UDID" || true + xcrun simctl bootstatus "$UDID" -b || true + echo "udid=$UDID" >> "$GITHUB_OUTPUT" + + - name: Run E2E suite on simulator + working-directory: purchasely/example/e2e + run: bash ./tools/ci_run_e2e_ios.sh "${{ steps.sim.outputs.udid }}" + + - name: Upload E2E logs + if: always() + uses: actions/upload-artifact@v7 + with: + name: e2e-ios-logs + path: purchasely/example/e2e/ci-logs/ + retention-days: 7 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index d5769ee..e45a359 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -24,10 +24,10 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v5 with: node-version: "24" registry-url: "https://registry.npmjs.org" @@ -50,10 +50,22 @@ jobs: echo "✅ All versions match release tag: $TAG" + # Prerelease tags (e.g. 6.0.0-rc.1) publish under the `next` dist-tag so they + # do NOT become npm `latest`; stable tags publish under `latest`. + - name: Determine npm dist-tag + id: npmtag + run: | + if [[ "${GITHUB_REF_NAME}" == *-* ]]; then + echo "tag=next" >> "$GITHUB_OUTPUT" + else + echo "tag=latest" >> "$GITHUB_OUTPUT" + fi + echo "npm dist-tag: $(grep -oP 'tag=\K.*' "$GITHUB_OUTPUT" | tail -1)" + - name: Publish @purchasely/cordova-plugin-purchasely working-directory: purchasely - run: npm publish --access public --provenance + run: npm publish --access public --provenance --tag ${{ steps.npmtag.outputs.tag }} - name: Publish @purchasely/cordova-plugin-purchasely-google working-directory: purchasely-google - run: npm publish --access public --provenance + run: npm publish --access public --provenance --tag ${{ steps.npmtag.outputs.tag }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..71803ad --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +# macOS +.DS_Store + +# IDE artifacts (regenerated locally; not shared) +.idea/caches/ +.idea/copilot.data.migration.*.xml +.idea/markdown.xml +.idea/inspectionProfiles/ diff --git a/CLAUDE.md b/CLAUDE.md index 6a2643e..0ef1421 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -83,4 +83,5 @@ Cordova SDK, iOS SDK, and Android SDK versions are usually close but **may diffe ## CI/CD - **`ci.yml`** — Runs on PRs: unit tests, iOS build, Android build, version consistency check -- **`publish.yml`** — Runs on GitHub release: calls CI first, then publishes both npm packages via OIDC trusted publishing (no npm token stored) +- **`publish.yml`** — Runs on GitHub release: calls CI first, then publishes both npm packages via OIDC trusted publishing (no npm token stored). Prerelease tags (`X.Y.Z-*`, e.g. `6.0.0-rc.1`) publish under the npm `next` dist-tag; stable tags under `latest`. Mark the GitHub release "pre-release" for RCs. +- **`e2e-android.yml` / `e2e-ios.yml`** — Device E2E (Appium + WebdriverIO) against the real backend; run on `workflow_dispatch`, nightly, and scoped PRs (not part of `ci.yml`). See `purchasely/example/e2e/README.md`. diff --git a/MIGRATION-v6.md b/MIGRATION-v6.md new file mode 100644 index 0000000..b4196f1 --- /dev/null +++ b/MIGRATION-v6.md @@ -0,0 +1,179 @@ +# Migrating the Purchasely Cordova plugin to 6.0 + +`@purchasely/cordova-plugin-purchasely@6.0.0-rc.1` wraps the Purchasely **6.0** native +SDKs (iOS `Purchasely 6.0.0-rc.2`, Android `io.purchasely:core 6.0.0-rc.2`). This guide +lists every change to the JavaScript API. Most of the SDK is source-compatible; the +breaking surfaces are **SDK start**, **presentation display mode**, and the **action +interceptor result**. + +> The 6.0 line is a release candidate. It is published to npm under the `next` dist-tag: +> `cordova plugin add @purchasely/cordova-plugin-purchasely@next`. + +## Install + +```bash +cordova plugin add @purchasely/cordova-plugin-purchasely@6.0.0-rc.1 +cordova plugin add @purchasely/cordova-plugin-purchasely-google@6.0.0-rc.1 # Google Play +``` + +--- + +## 1. `start()` now takes an options object (breaking) + +The positional argument list is replaced by a single configuration object. + +```js +// Before (5.x) +Purchasely.start( + 'YOUR_API_KEY', + ['Google'], + false, // storeKit1 + null, // userId + Purchasely.LogLevel.DEBUG, + Purchasely.RunningMode.full, + (isConfigured) => { /* ... */ }, + (error) => { /* ... */ } +); + +// After (6.0) +Purchasely.start( + { + apiKey: 'YOUR_API_KEY', + stores: [Purchasely.Store.google], // Store.google | Store.huawei | Store.amazon + storeKit1: false, // iOS only + appUserId: null, + logLevel: Purchasely.LogLevel.DEBUG, + runningMode: Purchasely.RunningMode.full, + allowDeeplink: true, // optional + allowCampaigns: true, // optional + }, + (isConfigured) => { /* ... */ }, + (error) => { /* ... */ } +); +``` + +Recognised options: `apiKey` (required), `appUserId`, `logLevel`, `runningMode`, `stores`, +`storeKit1` / `storekitVersion` (iOS), `allowDeeplink`, `allowCampaigns`, `deeplink` +(cold‑start URL). + +## 2. `RunningMode` — new values, new default + +Native 6.0 removed `transactionOnly` and `paywallObserver`; only **`observer`** and +**`full`** remain, and the SDK now **defaults to `observer`** (5.x behaved like `full`). + +- `RunningMode` values are now **name strings** (`'observer'` / `'full'`), not integers — + the underlying native enums use different raw values per platform, so the bridge maps by + name. If you passed the numeric constants (`Purchasely.RunningMode.full`) you don't need + to change anything. +- `RunningMode.paywallObserver` was **removed** — use `observer`. +- **To keep the 5.x behaviour where Purchasely owns the purchase flow, pass + `runningMode: Purchasely.RunningMode.full`.** + +## 3. `synchronize()` now reports completion + +```js +// Before: fire-and-forget +Purchasely.synchronize(); + +// After: success / error callbacks +Purchasely.synchronize( + (ok) => console.log('synchronized', ok), + (error) => console.log(error) // e.g. BillingUnavailable +); +``` + +## 4. Presentation display mode replaces `isFullscreen` + +The `isFullscreen` boolean on the `present*` methods is replaced by a **display mode +string** (`Purchasely.TransitionType`). Booleans are still accepted for compatibility +(`true` → `fullScreen`, `false` → `modal`). + +```js +// Before +Purchasely.presentPresentationForPlacement('ONBOARDING', null, true, ok, err); + +// After +Purchasely.presentPresentationForPlacement( + 'ONBOARDING', null, Purchasely.TransitionType.fullScreen, ok, err +); +``` + +`TransitionType`: `fullScreen`, `modal`, `drawer`, `popin`, `push`, `inlinePaywall`. + +### Removed presentation methods + +| Removed | Replacement | +|---|---| +| `presentSubscriptions()` | No native screen in 6.0. Build your own from `userSubscriptions()` / `userSubscriptionsHistory()`. | +| `presentProductWithIdentifier()` | Use placement/screen-based presentation (`presentPresentationForPlacement` / `presentPresentationWithIdentifier`). | +| `presentPlanWithIdentifier()` | Same as above. | +| `showPresentation()` / `hidePresentation()` | No hide/show primitive in 6.0. Use `closePresentation()`; `backPresentation()` was added to navigate back. | + +## 5. Action interceptor + +Purchasely 6.0 intercepts actions **per kind**, matching the native SDK. Register a +handler for each action you care about with `interceptAction(kind, handler)`. The handler +receives `(info, parameters)` and returns — or resolves to — an `InterceptResult` +(`success`, `failed`, `notHandled`). + +```js +Purchasely.interceptAction(Purchasely.PresentationAction.purchase, (info, parameters) => { + // let the SDK proceed with the purchase + return Purchasely.InterceptResult.notHandled; +}); + +Purchasely.interceptAction(Purchasely.PresentationAction.login, (info, parameters) => { + // the app fully handled this action + Purchasely.userLogin('MY_USER_ID'); + return Purchasely.InterceptResult.success; +}); + +// Stop intercepting one kind, or all of them: +Purchasely.removeActionInterceptor(Purchasely.PresentationAction.purchase); +Purchasely.removeAllActionInterceptors(); +``` + +- `InterceptResult`: `success`, `failed`, `notHandled`. +- Handlers may return a value or a `Promise`; async work (e.g. showing your own login + screen) is supported — report the result once it resolves. +- Each intercept resolves independently, so concurrent intercepts never clobber one another. + +### Removed: the single global interceptor + +`setPaywallActionInterceptor(callback)` + `onProcessAction(result)` were **removed**. +Migrate to per-action `interceptAction(kind, handler)`; each handler reports its outcome +by returning an `InterceptResult` instead of calling `onProcessAction`. + +- The `PaywallAction` constant was **renamed to `PresentationAction`** (same string + values); the old `PaywallAction` name was removed. + +## 6. Deeplink API renames + +| Before (removed) | After | +|---|---| +| `readyToOpenDeeplink(allow)` | `allowDeeplink(allow)` | +| `isDeeplinkHandled(url, ok, err)` | `handleDeeplink(url, ok, err)` | +| — | `allowCampaigns(allow)` (new) | + +The old `readyToOpenDeeplink` / `isDeeplinkHandled` names were **removed** — use the new ones. + +## 7. Default dismiss handler rename + +`setDefaultPresentationResultHandler(success, error)` → **`setDefaultPresentationDismissHandler(success, error)`** +(old name **removed**). The dismiss outcome now also carries a +`closeReason` (`button` / `back_system` / `programmatic`), matching the native +PLYCloseReason contract shared with the Flutter bridge. + +> iOS note: a swipe-to-dismiss is reported as `back_system`; a close with no +> dismiss reason (e.g. after a purchase) omits `closeReason`. + +## 8. New exported constants + +`InterceptResult`, `PresentationType`, `CloseReason`, `TransitionType`, `DimensionType`, +`Store`, `StorekitVersion`, `PresentationAction`. + +--- + +See `VERSIONS.md` for the Cordova ↔ native SDK version mapping, and the native release +notes for [Purchasely-iOS 6.0.0-rc.2](https://github.com/Purchasely/Purchasely-iOS/releases) +and [Purchasely-Android 6.0.0-rc.2](https://github.com/Purchasely/Purchasely-Android/releases). diff --git a/README.md b/README.md index 0d55f20..5f91d40 100644 --- a/README.md +++ b/README.md @@ -15,16 +15,21 @@ cordova plugin add @purchasely/cordova-plugin-purchasely-google ## Usage -More details in our [documentation](https://docs.purchasely.com/quick-start/sdk-implementation) +More details in our [documentation](https://docs.purchasely.com/quick-start/sdk-implementation). + +> **Upgrading from 5.x?** See [MIGRATION-v6.md](MIGRATION-v6.md) — `start()` now takes an +> options object, `RunningMode` defaults to `observer`, and a few methods were renamed. ```js Purchasely.start( - 'API_KEY', // set your own api key - ['Google'], // list of stores for Android, accepted values: Google, Huawei and Amazon - false, // set to false to use StoreKit2, true to use StoreKit1 - null, // set your user id - Purchasely.LogLevel.DEBUG, // log level, should be warning or error in production - Purchasely.RunningMode.full, // running mode, can be paywallObserver or full + { + apiKey: 'API_KEY', // set your own api key + stores: [Purchasely.Store.google], // Android stores: Store.google, Store.huawei, Store.amazon + storeKit1: false, // iOS: false to use StoreKit2, true for StoreKit1 + appUserId: null, // set your user id + logLevel: Purchasely.LogLevel.DEBUG, // should be warning or error in production + runningMode: Purchasely.RunningMode.full // observer or full (defaults to observer) + }, (isConfigured) => { if(isConfigured) { // Purchasely is ready, you can display paywalls, set user attributes, start a purchase flow etc. @@ -39,7 +44,7 @@ Purchasely.start( Purchasely.presentPresentationForPlacement( 'placementId', 'my_content_id', // may be null - false, //display in fullscreen mode + Purchasely.TransitionType.fullScreen, // display mode (callback) => { console.log(callback); if(callback.result == Purchasely.PurchaseResult.CANCELLED) { diff --git a/VERSIONS.md b/VERSIONS.md index aeddcbf..a8201e4 100644 --- a/VERSIONS.md +++ b/VERSIONS.md @@ -45,4 +45,5 @@ This file provides the underlying native SDK versions that the Cordova SDK relie | 5.7.0 | 5.7.0 | 5.7.0 | | 5.7.1 | 5.7.1 | 5.7.1 | | 5.7.2 | 5.7.2 | 5.7.3 | -| 5.7.3 | 5.7.4 | 5.7.4 | \ No newline at end of file +| 5.7.3 | 5.7.4 | 5.7.4 | +| 6.0.0-rc.1 | 6.0.0-rc.2 | 6.0.0-rc.2 | \ No newline at end of file diff --git a/purchasely-google/package.json b/purchasely-google/package.json index 4f3c2e4..3c62b35 100644 --- a/purchasely-google/package.json +++ b/purchasely-google/package.json @@ -1,6 +1,6 @@ { "name": "@purchasely/cordova-plugin-purchasely-google", - "version": "5.7.3", + "version": "6.0.0-rc.1", "description": "Purchasely Google plugin for Android devices", "cordova": { "id": "@purchasely/cordova-plugin-purchasely-google", diff --git a/purchasely-google/plugin.xml b/purchasely-google/plugin.xml index 4f8f218..9a2f0bc 100644 --- a/purchasely-google/plugin.xml +++ b/purchasely-google/plugin.xml @@ -1,5 +1,5 @@ - + PurchaselyGoogle @@ -12,7 +12,7 @@ - + diff --git a/purchasely/README.md b/purchasely/README.md index de723ac..da626ff 100644 --- a/purchasely/README.md +++ b/purchasely/README.md @@ -10,15 +10,19 @@ cordova plugin add @purchasely/cordova-plugin-purchasely ## Usage +> **Upgrading from 5.x?** See [MIGRATION-v6.md](../MIGRATION-v6.md). + ```js -Purchasely.startWithAPIKey( - 'API_KEY', - ['Google'], // list of stores for Android, accepted values: Google, Huawei and Amazon - null, // your user id - Purchasely.LogLevel.DEBUG, // log level, should be warning or error in production - Purchasely.RunningMode.full, // running mode +Purchasely.start( + { + apiKey: 'API_KEY', + stores: [Purchasely.Store.google], // Android stores: Store.google, Store.huawei, Store.amazon + appUserId: null, // your user id + logLevel: Purchasely.LogLevel.DEBUG, // should be warning or error in production + runningMode: Purchasely.RunningMode.full // observer or full (defaults to observer) + }, (isConfigured) => { - if(isConfigured) // you can use the SDK like display a paywall or make a purchase + if(isConfigured) {} // you can use the SDK like display a paywall or make a purchase }, (error) => { console.log(error); @@ -28,7 +32,7 @@ Purchasely.startWithAPIKey( Purchasely.presentPresentationWithIdentifier( 'my_presentation_id', // may be null 'my_content_id', // may be null - false, //display in fullscreen mode + Purchasely.TransitionType.fullScreen, // display mode (callback) => { console.log(callback); if(callback.result == Purchasely.PurchaseResult.CANCELLED) { diff --git a/purchasely/__tests__/Purchasely.test.js b/purchasely/__tests__/Purchasely.test.js index e4328f2..8bb7e28 100644 --- a/purchasely/__tests__/Purchasely.test.js +++ b/purchasely/__tests__/Purchasely.test.js @@ -97,24 +97,82 @@ describe('Purchasely', () => { }); describe('RunningMode', () => { - it('should have correct running mode values', () => { - expect(Purchasely.RunningMode.paywallObserver).toBe(2); - expect(Purchasely.RunningMode.full).toBe(3); + it('should expose the Purchasely 6.0 running modes by name', () => { + expect(Purchasely.RunningMode.observer).toBe('observer'); + expect(Purchasely.RunningMode.full).toBe('full'); }); }); - describe('PaywallAction', () => { - it('should have correct paywall action values', () => { - expect(Purchasely.PaywallAction.close).toBe('close'); - expect(Purchasely.PaywallAction.close_all).toBe('close_all'); - expect(Purchasely.PaywallAction.login).toBe('login'); - expect(Purchasely.PaywallAction.navigate).toBe('navigate'); - expect(Purchasely.PaywallAction.purchase).toBe('purchase'); - expect(Purchasely.PaywallAction.restore).toBe('restore'); - expect(Purchasely.PaywallAction.open_presentation).toBe('open_presentation'); - expect(Purchasely.PaywallAction.open_placement).toBe('open_placement'); - expect(Purchasely.PaywallAction.promo_code).toBe('promo_code'); - expect(Purchasely.PaywallAction.web_checkout).toBe('web_checkout'); + describe('PresentationAction', () => { + it('should have correct presentation action values', () => { + expect(Purchasely.PresentationAction.close).toBe('close'); + expect(Purchasely.PresentationAction.close_all).toBe('close_all'); + expect(Purchasely.PresentationAction.login).toBe('login'); + expect(Purchasely.PresentationAction.navigate).toBe('navigate'); + expect(Purchasely.PresentationAction.purchase).toBe('purchase'); + expect(Purchasely.PresentationAction.restore).toBe('restore'); + expect(Purchasely.PresentationAction.open_presentation).toBe('open_presentation'); + expect(Purchasely.PresentationAction.open_placement).toBe('open_placement'); + expect(Purchasely.PresentationAction.promo_code).toBe('promo_code'); + expect(Purchasely.PresentationAction.web_checkout).toBe('web_checkout'); + }); + }); + + describe('InterceptResult', () => { + it('should have correct intercept result values', () => { + expect(Purchasely.InterceptResult.success).toBe('success'); + expect(Purchasely.InterceptResult.failed).toBe('failed'); + expect(Purchasely.InterceptResult.notHandled).toBe('notHandled'); + }); + }); + + describe('PresentationType', () => { + it('should have correct presentation type values', () => { + expect(Purchasely.PresentationType.normal).toBe(0); + expect(Purchasely.PresentationType.fallback).toBe(1); + expect(Purchasely.PresentationType.deactivated).toBe(2); + expect(Purchasely.PresentationType.client).toBe(3); + }); + }); + + describe('CloseReason', () => { + it('should have correct close reason values', () => { + expect(Purchasely.CloseReason.button).toBe('button'); + expect(Purchasely.CloseReason.backSystem).toBe('back_system'); + expect(Purchasely.CloseReason.programmatic).toBe('programmatic'); + }); + }); + + describe('TransitionType', () => { + it('should have correct transition type values', () => { + expect(Purchasely.TransitionType.fullScreen).toBe('fullScreen'); + expect(Purchasely.TransitionType.modal).toBe('modal'); + expect(Purchasely.TransitionType.drawer).toBe('drawer'); + expect(Purchasely.TransitionType.popin).toBe('popin'); + expect(Purchasely.TransitionType.push).toBe('push'); + expect(Purchasely.TransitionType.inlinePaywall).toBe('inlinePaywall'); + }); + }); + + describe('DimensionType', () => { + it('should have correct dimension type values', () => { + expect(Purchasely.DimensionType.pixel).toBe('pixel'); + expect(Purchasely.DimensionType.percentage).toBe('percentage'); + }); + }); + + describe('Store', () => { + it('should have correct store values', () => { + expect(Purchasely.Store.google).toBe('Google'); + expect(Purchasely.Store.huawei).toBe('Huawei'); + expect(Purchasely.Store.amazon).toBe('Amazon'); + }); + }); + + describe('StorekitVersion', () => { + it('should have correct storekit version values', () => { + expect(Purchasely.StorekitVersion.storeKit1).toBe('storeKit1'); + expect(Purchasely.StorekitVersion.storeKit2).toBe('storeKit2'); }); }); @@ -135,18 +193,39 @@ describe('Purchasely', () => { }); describe('start', () => { - it('should call exec with correct parameters', () => { + it('should call exec with a single options object (with sdkVersion appended)', () => { const success = jest.fn(); const error = jest.fn(); - Purchasely.start('API_KEY', ['GOOGLE'], false, 'user123', 1, 3, success, error); + Purchasely.start( + { + apiKey: 'API_KEY', + stores: [Purchasely.Store.google], + storeKit1: false, + appUserId: 'user123', + logLevel: Purchasely.LogLevel.INFO, + runningMode: Purchasely.RunningMode.full + }, + success, + error + ); expect(mockExec).toHaveBeenCalledWith( success, error, 'Purchasely', 'start', - ['API_KEY', ['GOOGLE'], false, 'user123', 1, 3, '5.6.2'] + [ + { + apiKey: 'API_KEY', + stores: ['Google'], + storeKit1: false, + appUserId: 'user123', + logLevel: 1, + runningMode: 'full', + sdkVersion: '5.6.2' + } + ] ); }); }); @@ -270,33 +349,56 @@ describe('Purchasely', () => { }); }); - describe('readyToOpenDeeplink', () => { + describe('allowDeeplink', () => { it('should call exec with correct parameters', () => { - Purchasely.readyToOpenDeeplink(true); + Purchasely.allowDeeplink(true); expect(mockExec).toHaveBeenCalledWith( expect.any(Function), expect.any(Function), 'Purchasely', - 'readyToOpenDeeplink', + 'allowDeeplink', [true] ); }); }); - describe('setDefaultPresentationResultHandler', () => { + describe('allowCampaigns', () => { + it('should call exec with correct parameters', () => { + Purchasely.allowCampaigns(false); + + expect(mockExec).toHaveBeenCalledWith( + expect.any(Function), + expect.any(Function), + 'Purchasely', + 'allowCampaigns', + [false] + ); + }); + }); + + describe('setDefaultPresentationDismissHandler', () => { it('should call exec with correct parameters', () => { const success = jest.fn(); const error = jest.fn(); - Purchasely.setDefaultPresentationResultHandler(success, error); + Purchasely.setDefaultPresentationDismissHandler(success, error); - expect(mockExec).toHaveBeenCalledWith(success, error, 'Purchasely', 'setDefaultPresentationResultHandler', []); + expect(mockExec).toHaveBeenCalledWith(success, error, 'Purchasely', 'setDefaultPresentationDismissHandler', []); }); }); describe('synchronize', () => { - it('should call exec with correct parameters', () => { + it('should call exec with the provided callbacks', () => { + const success = jest.fn(); + const error = jest.fn(); + + Purchasely.synchronize(success, error); + + expect(mockExec).toHaveBeenCalledWith(success, error, 'Purchasely', 'synchronize', []); + }); + + it('should default callbacks when none provided', () => { Purchasely.synchronize(); expect(mockExec).toHaveBeenCalledWith( @@ -310,69 +412,50 @@ describe('Purchasely', () => { }); describe('presentPresentationWithIdentifier', () => { - it('should call exec with correct parameters', () => { + it('should pass a display mode string', () => { const success = jest.fn(); const error = jest.fn(); - Purchasely.presentPresentationWithIdentifier('presentation1', 'content1', true, success, error); + Purchasely.presentPresentationWithIdentifier('presentation1', 'content1', Purchasely.TransitionType.drawer, success, error); expect(mockExec).toHaveBeenCalledWith( success, error, 'Purchasely', 'presentPresentationWithIdentifier', - ['presentation1', 'content1', true] + ['presentation1', 'content1', 'drawer'] ); }); - }); - describe('presentPresentationForPlacement', () => { - it('should call exec with correct parameters', () => { + it('should normalize a legacy isFullscreen=true to fullScreen', () => { const success = jest.fn(); const error = jest.fn(); - Purchasely.presentPresentationForPlacement('placement1', 'content1', false, success, error); - - expect(mockExec).toHaveBeenCalledWith( - success, - error, - 'Purchasely', - 'presentPresentationForPlacement', - ['placement1', 'content1', false] - ); - }); - }); - - describe('presentProductWithIdentifier', () => { - it('should call exec with correct parameters', () => { - const success = jest.fn(); - const error = jest.fn(); - - Purchasely.presentProductWithIdentifier('product1', 'presentation1', 'content1', true, success, error); + Purchasely.presentPresentationWithIdentifier('presentation1', 'content1', true, success, error); expect(mockExec).toHaveBeenCalledWith( success, error, 'Purchasely', - 'presentProductWithIdentifier', - ['product1', 'presentation1', 'content1', true] + 'presentPresentationWithIdentifier', + ['presentation1', 'content1', 'fullScreen'] ); }); }); - describe('presentPlanWithIdentifier', () => { - it('should call exec with correct parameters', () => { + describe('presentPresentationForPlacement', () => { + it('should normalize a legacy isFullscreen=false to modal', () => { const success = jest.fn(); const error = jest.fn(); - Purchasely.presentPlanWithIdentifier('plan1', 'presentation1', 'content1', false, success, error); + Purchasely.presentPresentationForPlacement('placement1', 'content1', false, success, error); expect(mockExec).toHaveBeenCalledWith( success, error, 'Purchasely', - 'presentPlanWithIdentifier', - ['plan1', 'presentation1', 'content1', false] + 'presentPresentationForPlacement', + ['placement1', 'content1', 'modal'] ); }); }); @@ -412,33 +495,19 @@ describe('Purchasely', () => { }); describe('presentPresentation', () => { - it('should call exec with correct parameters', () => { + it('should call exec with a display mode string', () => { const success = jest.fn(); const error = jest.fn(); const presentation = { id: 'test' }; - Purchasely.presentPresentation(presentation, true, '#FFFFFF', success, error); + Purchasely.presentPresentation(presentation, Purchasely.TransitionType.fullScreen, '#FFFFFF', success, error); expect(mockExec).toHaveBeenCalledWith( success, error, 'Purchasely', 'presentPresentation', - [presentation, true, '#FFFFFF'] - ); - }); - }); - - describe('presentSubscriptions', () => { - it('should call exec with correct parameters', () => { - Purchasely.presentSubscriptions(); - - expect(mockExec).toHaveBeenCalledWith( - expect.any(Function), - expect.any(Function), - 'Purchasely', - 'presentSubscriptions', - [] + [presentation, 'fullScreen', '#FFFFFF'] ); }); }); @@ -493,18 +562,18 @@ describe('Purchasely', () => { }); }); - describe('isDeeplinkHandled', () => { + describe('handleDeeplink', () => { it('should call exec with correct parameters', () => { const success = jest.fn(); const error = jest.fn(); - Purchasely.isDeeplinkHandled('https://example.com/deeplink', success, error); + Purchasely.handleDeeplink('https://example.com/deeplink', success, error); expect(mockExec).toHaveBeenCalledWith( success, error, 'Purchasely', - 'isDeeplinkHandled', + 'handleDeeplink', ['https://example.com/deeplink'] ); }); @@ -559,36 +628,113 @@ describe('Purchasely', () => { }); }); - describe('setPaywallActionInterceptor', () => { - it('should call exec with correct parameters', () => { - const success = jest.fn(); + // Flush pending microtasks (the interceptor result is reported through a Promise chain). + const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); + + // Returns the native success callback exec() was given for a registerActionInterceptor(kind). + const nativeInterceptorFor = (kind) => { + const call = mockExec.mock.calls.find( + (c) => c[3] === 'registerActionInterceptor' && c[4][0] === kind + ); + return call && call[0]; + }; - Purchasely.setPaywallActionInterceptor(success); + describe('interceptAction (v6 per-action)', () => { + it('registers a native interceptor for the given kind', () => { + Purchasely.interceptAction('purchase', jest.fn()); expect(mockExec).toHaveBeenCalledWith( - success, + expect.any(Function), expect.any(Function), 'Purchasely', - 'setPaywallActionInterceptor', - [] + 'registerActionInterceptor', + ['purchase'] + ); + }); + + it('invokes the handler with (info, parameters) and reports its result', async () => { + const handler = jest.fn().mockReturnValue(Purchasely.InterceptResult.success); + Purchasely.interceptAction('purchase', handler); + const nativeSuccess = nativeInterceptorFor('purchase'); + mockExec.mockClear(); + + const event = { + action: 'purchase', + callbackId: 'purchase#1', + info: { contentId: 'c1' }, + parameters: { plan: 'p1' }, + }; + nativeSuccess(event); + await flush(); + + expect(handler).toHaveBeenCalledWith(event.info, event.parameters); + expect(mockExec).toHaveBeenCalledWith( + expect.any(Function), + expect.any(Function), + 'Purchasely', + 'completeActionInterceptor', + ['purchase#1', 'success'] + ); + }); + + it('ignores events whose action does not match the registered kind', async () => { + const handler = jest.fn(); + Purchasely.interceptAction('restore', handler); + const nativeSuccess = nativeInterceptorFor('restore'); + + nativeSuccess({ action: 'purchase', callbackId: 'purchase#2' }); + await flush(); + + expect(handler).not.toHaveBeenCalled(); + }); + + it('reports failed when the handler throws', async () => { + Purchasely.interceptAction('purchase', () => { throw new Error('boom'); }); + const nativeSuccess = nativeInterceptorFor('purchase'); + mockExec.mockClear(); + + nativeSuccess({ action: 'purchase', callbackId: 'purchase#3' }); + await flush(); + + expect(mockExec).toHaveBeenCalledWith( + expect.any(Function), + expect.any(Function), + 'Purchasely', + 'completeActionInterceptor', + ['purchase#3', 'failed'] ); }); }); - describe('onProcessAction', () => { - it('should call exec with correct parameters', () => { - Purchasely.onProcessAction(true); + describe('removeActionInterceptor', () => { + it('unregisters a single kind', () => { + Purchasely.removeActionInterceptor('purchase'); expect(mockExec).toHaveBeenCalledWith( expect.any(Function), expect.any(Function), 'Purchasely', - 'onProcessAction', - [true] + 'unregisterActionInterceptor', + ['purchase'] ); }); }); + describe('removeAllActionInterceptors', () => { + it('unregisters every registered kind', () => { + Purchasely.interceptAction('purchase', jest.fn()); + Purchasely.interceptAction('restore', jest.fn()); + mockExec.mockClear(); + + Purchasely.removeAllActionInterceptors(); + + const unregistered = mockExec.mock.calls + .filter((c) => c[3] === 'unregisterActionInterceptor') + .map((c) => c[4][0]); + expect(unregistered).toEqual(expect.arrayContaining(['purchase', 'restore'])); + }); + }); + describe('userDidConsumeSubscriptionContent', () => { it('should call exec with correct parameters', () => { Purchasely.userDidConsumeSubscriptionContent(); @@ -651,43 +797,29 @@ describe('Purchasely', () => { }); }); - describe('showPresentation', () => { - it('should call exec with correct parameters', () => { - Purchasely.showPresentation(); - - expect(mockExec).toHaveBeenCalledWith( - expect.any(Function), - expect.any(Function), - 'Purchasely', - 'showPresentation', - [] - ); - }); - }); - - describe('hidePresentation', () => { + describe('closePresentation', () => { it('should call exec with correct parameters', () => { - Purchasely.hidePresentation(); + Purchasely.closePresentation(); expect(mockExec).toHaveBeenCalledWith( expect.any(Function), expect.any(Function), 'Purchasely', - 'hidePresentation', + 'closePresentation', [] ); }); }); - describe('closePresentation', () => { + describe('backPresentation', () => { it('should call exec with correct parameters', () => { - Purchasely.closePresentation(); + Purchasely.backPresentation(); expect(mockExec).toHaveBeenCalledWith( expect.any(Function), expect.any(Function), 'Purchasely', - 'closePresentation', + 'backPresentation', [] ); }); diff --git a/purchasely/example/config.xml b/purchasely/example/config.xml index 70a1221..ce55347 100644 --- a/purchasely/example/config.xml +++ b/purchasely/example/config.xml @@ -19,16 +19,6 @@ - diff --git a/purchasely/example/e2e/.gitignore b/purchasely/example/e2e/.gitignore new file mode 100644 index 0000000..94deee0 --- /dev/null +++ b/purchasely/example/e2e/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +ci-logs/ +package-lock.json diff --git a/purchasely/example/e2e/README.md b/purchasely/example/e2e/README.md new file mode 100644 index 0000000..95e5c7e --- /dev/null +++ b/purchasely/example/e2e/README.md @@ -0,0 +1,44 @@ +# Purchasely Cordova — End-to-End tests + +Device-level E2E tests for the example app, driving the **real** Purchasely 6.0 native +SDK through the Cordova bridge. Built with **Appium + WebdriverIO**: + +- **WEBVIEW context** — calls `window.Purchasely.*` and asserts the results directly + (the deterministic `bridge` suite). +- **NATIVE_APP context** — injects OS-level touches (interceptor / dismiss suites). + +These mirror the Flutter `integration_test` suite (`E2E_TEST_INDEX.md`) adapted to the +Cordova imperative API. They are **not** part of the PR-gating `ci.yml`; they run via the +`E2E Android` / `E2E iOS` workflows (`workflow_dispatch`, nightly `schedule`, and scoped +`pull_request`). + +## Suites & gating + +| Suite | File | Gate | Notes | +|-------|------|------|-------| +| bridge | `specs/bridge.e2e.js` | **hard** | anonymous id, allProducts, fetchPresentationForPlacement, synchronize completion, user-attribute round-trip | +| dismiss | `specs/dismiss.e2e.js` | best-effort | present placement + programmatic close → dismiss outcome + `closeReason` (needs a paywall to render) | + +Best-effort suites emit `::warning::` on failure and do not fail the job (native/paywall +rendering is flaky in CI — same policy as the Flutter suite). Each suite retries up to 3×. + +## Requirements to go green + +1. The placement `PURCHASELY_E2E_PLACEMENT` (default `ONBOARDING`) must exist on the + backend the example API key (`www/js/index.js`) points to, for app id + `com.purchasely.demo`. Override with the env var if your integration placement differs. +2. Android: an emulator with Google APIs (the workflow uses API 34, `pixel_6`). +3. iOS: an iPhone 16/15 simulator (the workflow discovers one). + +## Run locally + +```bash +# Android (emulator already booted, apk already built via `cordova build android`) +cd purchasely/example/e2e && npm install +bash ./tools/ci_run_e2e.sh emulator-5554 + +# iOS (simulator booted, app built via `cordova build ios --emulator`) +bash ./tools/ci_run_e2e_ios.sh +``` + +Logs are written to `purchasely/example/e2e/ci-logs/` (uploaded as CI artifacts). diff --git a/purchasely/example/e2e/helpers/driver.js b/purchasely/example/e2e/helpers/driver.js new file mode 100644 index 0000000..f6125b7 --- /dev/null +++ b/purchasely/example/e2e/helpers/driver.js @@ -0,0 +1,70 @@ +// Helpers for driving the Purchasely Cordova example app under Appium/WebdriverIO. +// +// Two contexts are used: +// * WEBVIEW — run JS against `window.Purchasely` (the cordova.exec bridge) and read +// results directly; used for the deterministic "bridge" assertions. +// * NATIVE_APP — inject OS-level touches (tap purchase button, press back) for the +// interceptor / dismiss suites. + +async function switchToWebview() { + await browser.waitUntil(async () => { + const contexts = await browser.getContexts(); + return contexts.some((c) => (typeof c === 'string' ? c : c.id).includes('WEBVIEW')); + }, { timeout: 60000, timeoutMsg: 'WEBVIEW context never appeared' }); + + const contexts = await browser.getContexts(); + const webview = contexts + .map((c) => (typeof c === 'string' ? c : c.id)) + .find((id) => id.includes('WEBVIEW')); + await browser.switchContext(webview); +} + +async function switchToNative() { + await browser.switchContext('NATIVE_APP'); +} + +// Wait until the Purchasely bridge is available (deviceready fired + plugin clobbered). +async function waitForPurchaselyReady() { + await switchToWebview(); + await browser.waitUntil( + async () => browser.execute(() => typeof window.Purchasely !== 'undefined'), + { timeout: 60000, timeoutMsg: 'window.Purchasely never became available' } + ); +} + +// Invoke a Purchasely method that takes trailing (success, error) callbacks and resolve +// with { ok, value } on success or { ok:false, error } on error. `args` are the leading +// positional arguments before the callbacks. +async function callBridge(method, args = [], timeoutMs = 30000) { + return browser.executeAsync( + function (method, args, timeoutMs, done) { + var settled = false; + var finish = function (payload) { + if (settled) return; + settled = true; + done(payload); + }; + setTimeout(function () { finish({ ok: false, error: 'timeout' }); }, timeoutMs); + try { + 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) }); }, + ]) + ); + } catch (e) { + finish({ ok: false, error: String(e) }); + } + }, + method, + args, + timeoutMs + ); +} + +module.exports = { switchToWebview, switchToNative, waitForPurchaselyReady, callBridge }; diff --git a/purchasely/example/e2e/package.json b/purchasely/example/e2e/package.json new file mode 100644 index 0000000..45383aa --- /dev/null +++ b/purchasely/example/e2e/package.json @@ -0,0 +1,19 @@ +{ + "name": "purchasely-cordova-e2e", + "version": "1.0.0", + "private": true, + "description": "End-to-end tests for the Purchasely Cordova example app (Appium + WebdriverIO). Drives the real native Purchasely 6.0 SDK inside the WebView (bridge assertions) and injects OS-level touches (interceptor / dismiss).", + "scripts": { + "test:android": "wdio run ./wdio.android.conf.js", + "test:ios": "wdio run ./wdio.ios.conf.js" + }, + "devDependencies": { + "@wdio/cli": "^9.0.0", + "@wdio/local-runner": "^9.0.0", + "@wdio/mocha-framework": "^9.0.0", + "@wdio/spec-reporter": "^9.0.0", + "appium": "^2.11.0", + "appium-uiautomator2-driver": "^3.9.0", + "appium-xcuitest-driver": "^7.28.0" + } +} diff --git a/purchasely/example/e2e/specs/bridge.e2e.js b/purchasely/example/e2e/specs/bridge.e2e.js new file mode 100644 index 0000000..8ecc7f4 --- /dev/null +++ b/purchasely/example/e2e/specs/bridge.e2e.js @@ -0,0 +1,51 @@ +// Deterministic Dart<->native bridge assertions (no native taps). HARD gate — these +// must pass. Mirrors the Flutter E2E_TEST_INDEX suite T1/T3/T5/T6 adapted to the +// Cordova imperative API. +const { waitForPurchaselyReady, callBridge } = require('../helpers/driver'); + +const PLACEMENT = process.env.PURCHASELY_E2E_PLACEMENT || 'ONBOARDING'; + +describe('Purchasely bridge (WEBVIEW context)', () => { + before(async () => { + await waitForPurchaselyReady(); + }); + + // T1 — anonymous user id + it('getAnonymousUserId returns a non-empty id', async () => { + const res = await callBridge('getAnonymousUserId'); + expect(res.ok).toBe(true); + expect(typeof res.value).toBe('string'); + expect(res.value.length).toBeGreaterThan(0); + }); + + // T5 — catalog + it('allProducts returns a list', async () => { + const res = await callBridge('allProducts'); + expect(res.ok).toBe(true); + expect(Array.isArray(res.value)).toBe(true); + }); + + // T3 — preload a presentation for a placement + it('fetchPresentationForPlacement returns a presentation object', async () => { + const res = await callBridge('fetchPresentationForPlacement', [PLACEMENT, null]); + expect(res.ok).toBe(true); + expect(res.value).toBeDefined(); + // v6 presentation carries an id/type so it can later be displayed. + expect(res.value === null || typeof res.value === 'object').toBe(true); + }); + + // T6 — synchronize now reports completion (v6 change). On a bare emulator/simulator + // with no billing, exactly one of success/error must fire (no fire-and-forget). + it('synchronize resolves exactly one callback', async () => { + const res = await callBridge('synchronize'); + expect(typeof res.ok).toBe('boolean'); + }); + + // user-attribute round-trip (set then read back) + it('setUserAttributeWithString then userAttribute round-trips', async () => { + await callBridge('setUserAttributeWithString', ['e2e_key', 'e2e_value', 'ESSENTIAL']); + const res = await callBridge('userAttribute', ['e2e_key']); + expect(res.ok).toBe(true); + expect(res.value).toBe('e2e_value'); + }); +}); diff --git a/purchasely/example/e2e/specs/dismiss.e2e.js b/purchasely/example/e2e/specs/dismiss.e2e.js new file mode 100644 index 0000000..e8dcfe0 --- /dev/null +++ b/purchasely/example/e2e/specs/dismiss.e2e.js @@ -0,0 +1,37 @@ +// Presentation display + dismiss outcome. BEST-EFFORT (non-blocking in CI): depends on +// a paywall actually rendering for the configured placement against the real backend. +// Mirrors E2E_TEST_INDEX T8/T12 adapted to the Cordova imperative API. +const { waitForPurchaselyReady, callBridge, switchToNative } = require('../helpers/driver'); + +const PLACEMENT = process.env.PURCHASELY_E2E_PLACEMENT || 'ONBOARDING'; + +describe('Presentation dismiss outcome', () => { + before(async () => { + await waitForPurchaselyReady(); + }); + + // T8/T12 — the present* success callback IS the per-presentation dismiss outcome. + // Present a placement, close it programmatically, and assert the outcome fires with a + // closeReason. + it('presentPresentationForPlacement + closePresentation delivers a dismiss outcome', async () => { + // Kick off the presentation; do NOT await (the callback resolves at dismiss). + const outcomePromise = callBridge( + 'presentPresentationForPlacement', + [PLACEMENT, null, 'fullScreen'], + 90000 + ); + + // Give the paywall time to render, then close it programmatically from the bridge. + await browser.pause(6000); + await callBridge('closePresentation'); + + const outcome = await outcomePromise; + expect(outcome.ok).toBe(true); + expect(outcome.value).toBeDefined(); + // v6 outcome carries a closeReason; programmatic close => 'programmatic' (Android). + if (outcome.value && outcome.value.closeReason) { + expect(typeof outcome.value.closeReason).toBe('string'); + } + await switchToNative().catch(() => {}); + }); +}); diff --git a/purchasely/example/e2e/tools/ci_run_e2e.sh b/purchasely/example/e2e/tools/ci_run_e2e.sh new file mode 100755 index 0000000..5e503d8 --- /dev/null +++ b/purchasely/example/e2e/tools/ci_run_e2e.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# Android E2E runner. Assumes: +# * an emulator is already booted (serial passed as $1, e.g. emulator-5554) +# * the debug apk has been built (cordova build android) +# * node deps for purchasely/example/e2e are installed +# +# Gating mirrors the Flutter suite: the deterministic `bridge` suite HARD-gates the job; +# `dismiss` (needs a paywall to render against the real backend) is BEST-EFFORT and only +# emits ::warning:: on failure. +set -uo pipefail + +SERIAL="${1:-emulator-5554}" +HERE="$(cd "$(dirname "$0")/.." && pwd)" +LOGDIR="$HERE/ci-logs" +mkdir -p "$LOGDIR" +export ANDROID_SERIAL="$SERIAL" + +echo "== Starting Appium ==" +npx appium --log "$LOGDIR/appium-android.log" --log-level info & +APPIUM_PID=$! +trap 'kill $APPIUM_PID 2>/dev/null || true' EXIT +# Wait for Appium to accept connections. +for i in $(seq 1 30); do + curl -sf http://127.0.0.1:4723/status >/dev/null 2>&1 && break + sleep 1 +done + +run_suite() { # $1 = spec glob, $2 = hard|soft + local spec="$1" gate="$2" tries=3 n=1 + while [ $n -le $tries ]; do + echo "== [$gate] $spec (attempt $n/$tries) ==" + if PURCHASELY_E2E_SPEC="$spec" npx wdio run ./wdio.android.conf.js --spec "$spec" 2>&1 | tee "$LOGDIR/wdio-$(basename "$spec").log"; then + return 0 + fi + n=$((n+1)) + done + if [ "$gate" = "hard" ]; then + echo "::error::E2E suite failed (hard gate): $spec" + return 1 + fi + echo "::warning::E2E suite failed (best-effort): $spec" + return 0 +} + +cd "$HERE" +rc=0 +run_suite "./specs/bridge.e2e.js" hard || rc=1 +run_suite "./specs/dismiss.e2e.js" soft || true +exit $rc diff --git a/purchasely/example/e2e/tools/ci_run_e2e_ios.sh b/purchasely/example/e2e/tools/ci_run_e2e_ios.sh new file mode 100755 index 0000000..0351c95 --- /dev/null +++ b/purchasely/example/e2e/tools/ci_run_e2e_ios.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# iOS E2E runner. Assumes: +# * a simulator is already booted (udid passed as $1) +# * the simulator .app has been built (cordova build ios --emulator) +# * node deps for purchasely/example/e2e are installed +# +# Same gating as Android: `bridge` HARD-gates; `dismiss` is BEST-EFFORT. +set -uo pipefail + +UDID="${1:-booted}" +HERE="$(cd "$(dirname "$0")/.." && pwd)" +LOGDIR="$HERE/ci-logs" +mkdir -p "$LOGDIR" +export PURCHASELY_E2E_UDID="$UDID" + +echo "== Starting Appium ==" +npx appium --log "$LOGDIR/appium-ios.log" --log-level info & +APPIUM_PID=$! +trap 'kill $APPIUM_PID 2>/dev/null || true' EXIT +for i in $(seq 1 30); do + curl -sf http://127.0.0.1:4723/status >/dev/null 2>&1 && break + sleep 1 +done + +run_suite() { # $1 = spec, $2 = hard|soft + local spec="$1" gate="$2" tries=3 n=1 + while [ $n -le $tries ]; do + echo "== [$gate] $spec (attempt $n/$tries) ==" + if npx wdio run ./wdio.ios.conf.js --spec "$spec" 2>&1 | tee "$LOGDIR/wdio-$(basename "$spec").log"; then + return 0 + fi + n=$((n+1)) + done + if [ "$gate" = "hard" ]; then + echo "::error::E2E suite failed (hard gate): $spec" + return 1 + fi + echo "::warning::E2E suite failed (best-effort): $spec" + return 0 +} + +cd "$HERE" +rc=0 +run_suite "./specs/bridge.e2e.js" hard || rc=1 +run_suite "./specs/dismiss.e2e.js" soft || true +exit $rc diff --git a/purchasely/example/e2e/wdio.android.conf.js b/purchasely/example/e2e/wdio.android.conf.js new file mode 100644 index 0000000..81de342 --- /dev/null +++ b/purchasely/example/e2e/wdio.android.conf.js @@ -0,0 +1,21 @@ +const path = require('path'); +const { config } = require('./wdio.shared.conf'); + +// Path to the debug apk built by `cordova build android`. +const APK = process.env.PURCHASELY_E2E_APK || + path.resolve(__dirname, '../platforms/android/app/build/outputs/apk/debug/app-debug.apk'); + +exports.config = Object.assign({}, config, { + capabilities: [{ + platformName: 'Android', + 'appium:automationName': 'UiAutomator2', + 'appium:app': APK, + 'appium:appPackage': 'com.purchasely.demo', + 'appium:newCommandTimeout': 240, + 'appium:autoGrantPermissions': true, + // The Cordova WebView is debuggable in the debug build, so Appium can attach + // chromedriver and expose the WEBVIEW context. + 'appium:ensureWebviewsHavePages': true, + 'appium:nativeWebScreenshot': true, + }], +}); diff --git a/purchasely/example/e2e/wdio.ios.conf.js b/purchasely/example/e2e/wdio.ios.conf.js new file mode 100644 index 0000000..2da8195 --- /dev/null +++ b/purchasely/example/e2e/wdio.ios.conf.js @@ -0,0 +1,19 @@ +const path = require('path'); +const { config } = require('./wdio.shared.conf'); + +// Path to the .app built by `cordova build ios --emulator` (simulator build). +const APP = process.env.PURCHASELY_E2E_APP || + path.resolve(__dirname, '../platforms/ios/build/emulator/HelloCordova.app'); + +exports.config = Object.assign({}, config, { + capabilities: [{ + platformName: 'iOS', + 'appium:automationName': 'XCUITest', + 'appium:app': APP, + 'appium:bundleId': 'com.purchasely.demo', + 'appium:deviceName': process.env.PURCHASELY_E2E_SIM || 'iPhone 16', + 'appium:platformVersion': process.env.PURCHASELY_E2E_IOS_VERSION || undefined, + 'appium:newCommandTimeout': 240, + 'appium:autoAcceptAlerts': true, + }], +}); diff --git a/purchasely/example/e2e/wdio.shared.conf.js b/purchasely/example/e2e/wdio.shared.conf.js new file mode 100644 index 0000000..1eec31c --- /dev/null +++ b/purchasely/example/e2e/wdio.shared.conf.js @@ -0,0 +1,28 @@ +// Shared WebdriverIO config for the Purchasely Cordova E2E suite. +// Platform-specific configs (wdio.android.conf.js / wdio.ios.conf.js) extend this +// and set `capabilities`. +// +// The example app boots the Purchasely SDK on `deviceready` (see www/js/index.js). +// Tests switch to the WEBVIEW context and call `window.Purchasely.*` directly, and +// switch to NATIVE_APP to inject OS-level touches for the interceptor / dismiss suites. +exports.config = { + runner: 'local', + specs: ['./specs/**/*.e2e.js'], + maxInstances: 1, + logLevel: 'info', + bail: 0, + waitforTimeout: 20000, + connectionRetryTimeout: 120000, + connectionRetryCount: 2, + framework: 'mocha', + reporters: ['spec'], + mochaOpts: { + ui: 'bdd', + timeout: 120000, + }, + // Appium is started as a service by the CI runner scripts (tools/ci_run_e2e*.sh), + // so we point WDIO at the already-running server rather than the @wdio/appium-service. + hostname: '127.0.0.1', + port: 4723, + path: '/', +}; diff --git a/purchasely/example/package-lock.json b/purchasely/example/package-lock.json index 1ab40b4..644ebbd 100644 --- a/purchasely/example/package-lock.json +++ b/purchasely/example/package-lock.json @@ -11,15 +11,15 @@ "devDependencies": { "@purchasely/cordova-plugin-purchasely": "file:..", "@purchasely/cordova-plugin-purchasely-google": "file:../../purchasely-google", - "cordova-android": "^14.0.1", - "cordova-ios": "^8.0.0", + "cordova-android": "^15.0.0", + "cordova-ios": "^8.1.0", "cordova-plugin-purchasely": "file:..", "cordova-plugin-purchasely-google": "file:../../purchasely-google" } }, "..": { "name": "@purchasely/cordova-plugin-purchasely", - "version": "5.7.3", + "version": "6.0.0-rc.1", "dev": true, "license": "ISC", "devDependencies": { @@ -36,7 +36,7 @@ }, "../../purchasely-google": { "name": "@purchasely/cordova-plugin-purchasely-google", - "version": "5.7.3", + "version": "6.0.0-rc.1", "dev": true, "license": "ISC" }, @@ -101,19 +101,19 @@ } }, "node_modules/abbrev": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-3.0.1.tgz", - "integrity": "sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-4.0.0.tgz", + "integrity": "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==", "dev": true, "license": "ISC", "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/android-versions": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/android-versions/-/android-versions-2.1.0.tgz", - "integrity": "sha512-oCBvVs2uaL8ohQtesGs78/X7QvFDLbKgTosBRiOIBCss1a/yiakQm/ADuoG2k/AUaI0FfrsFeMl/a+GtEtjEeA==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/android-versions/-/android-versions-2.1.2.tgz", + "integrity": "sha512-JmyCaE2AWe/1ENDv70Bx1hBiVvkUV/JJYnAH6vFGENXDb2DdmCDMO9icoCvGR9TAsRJ/AuszEcIpyHM6qim3SA==", "dev": true, "license": "MIT", "dependencies": { @@ -193,57 +193,52 @@ } }, "node_modules/cordova-android": { - "version": "14.0.1", - "resolved": "https://registry.npmjs.org/cordova-android/-/cordova-android-14.0.1.tgz", - "integrity": "sha512-HMBMdGu/JlSQtmBuDEpKWf/pE75SpF3FksxZ+mqYuL3qSIN8lN/QsNurwYaPAP7zWXN2DNpvwlpOJItS5VhdLg==", + "version": "15.0.0", + "resolved": "https://registry.npmjs.org/cordova-android/-/cordova-android-15.0.0.tgz", + "integrity": "sha512-EpFSKUtBLJ7bTpuVD7NeC6toAooi5PI6VIR2jd8Ut5PJu7HSR5tPRwS87Q1DS03RSyDTlroB64JPUWC0pmAhnw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "android-versions": "^2.1.0", - "cordova-common": "^5.0.1", - "dedent": "^1.5.3", + "android-versions": "^2.1.1", + "cordova-common": "^6.0.0", + "dedent": "^1.7.1", "execa": "^5.1.1", "fast-glob": "^3.3.3", "is-path-inside": "^3.0.3", - "nopt": "^8.1.0", + "nopt": "^9.0.0", "properties-parser": "^0.6.0", - "semver": "^7.7.1", - "string-argv": "^0.3.1", + "semver": "^7.7.4", + "string-argv": "^0.3.2", "untildify": "^4.0.0", - "which": "^5.0.0" + "which": "^6.0.1" }, "engines": { - "node": ">=20.5.0" + "node": ">=20.17.0 || >=22.9.0" } }, "node_modules/cordova-common": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/cordova-common/-/cordova-common-5.0.1.tgz", - "integrity": "sha512-OA2NQ6wvhNz4GytPYwTdlA9xfG7Yf7ufkj4u97m3rUfoL/AECwwj0GVT2CYpk/0Fk6HyuHA3QYCxfDPYsKzI1A==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cordova-common/-/cordova-common-6.0.0.tgz", + "integrity": "sha512-16WPC1DuxVdshV3RoQUXqhcJVdhxWGwiFysA4TkYuboqoev6mgt0JuIJFxmQbzR/DuyuONaVe0L0O0Hf1C08Mg==", "dev": true, "license": "Apache-2.0", "dependencies": { "@netflix/nerror": "^1.1.3", "ansi": "^0.3.1", "bplist-parser": "^0.3.2", - "cross-spawn": "^7.0.6", "elementtree": "^0.1.7", "endent": "^2.1.0", "fast-glob": "^3.3.3", - "lodash.zip": "^4.2.0", - "plist": "^3.1.0", - "q": "^1.5.1", - "read-chunk": "^3.2.0", - "strip-bom": "^4.0.0" + "plist": "^3.1.0" }, "engines": { - "node": ">=16.0.0" + "node": ">=20.9.0" } }, "node_modules/cordova-ios": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/cordova-ios/-/cordova-ios-8.0.0.tgz", - "integrity": "sha512-QsynOV8rnRIhDC3qwM1KdRr1gy5RaqzVCzdOaB+DSBPeR5wVEUGpyyJO0FzlCACzY5X25iDc5O3kbn/F06dJ5Q==", + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/cordova-ios/-/cordova-ios-8.1.0.tgz", + "integrity": "sha512-IgVtJIQJBJi+QqqrGjx/moY+M9DqO+RQLeIpHGsZD1Xy+mGflDrp7lunmFYqVchV31b8O/aqE4y4AsDUZip04w==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -262,67 +257,6 @@ "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/cordova-ios/node_modules/abbrev": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-4.0.0.tgz", - "integrity": "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==", - "dev": true, - "license": "ISC", - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/cordova-ios/node_modules/cordova-common": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/cordova-common/-/cordova-common-6.0.0.tgz", - "integrity": "sha512-16WPC1DuxVdshV3RoQUXqhcJVdhxWGwiFysA4TkYuboqoev6mgt0JuIJFxmQbzR/DuyuONaVe0L0O0Hf1C08Mg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@netflix/nerror": "^1.1.3", - "ansi": "^0.3.1", - "bplist-parser": "^0.3.2", - "elementtree": "^0.1.7", - "endent": "^2.1.0", - "fast-glob": "^3.3.3", - "plist": "^3.1.0" - }, - "engines": { - "node": ">=20.9.0" - } - }, - "node_modules/cordova-ios/node_modules/nopt": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-9.0.0.tgz", - "integrity": "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==", - "dev": true, - "license": "ISC", - "dependencies": { - "abbrev": "^4.0.0" - }, - "bin": { - "nopt": "bin/nopt.js" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/cordova-ios/node_modules/which": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/which/-/which-6.0.0.tgz", - "integrity": "sha512-f+gEpIKMR9faW/JgAgPK1D7mekkFoqbmiwvNzuhsHetni20QSgzg9Vhn0g2JSJkkfehQnqdUAx7/e15qS1lPxg==", - "dev": true, - "license": "ISC", - "dependencies": { - "isexe": "^3.1.1" - }, - "bin": { - "node-which": "bin/which.js" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, "node_modules/cordova-plugin-purchasely": { "resolved": "..", "link": true @@ -364,9 +298,9 @@ } }, "node_modules/dedent": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.0.tgz", - "integrity": "sha512-HGFtf8yhuhGhqO07SV79tRp+br4MnbdjeVxotpn1QBl30pcLLCQjX5b2295ll0fv8RKDKsmWYrl05usHM9CewQ==", + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", + "integrity": "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==", "dev": true, "license": "MIT", "peerDependencies": { @@ -552,11 +486,13 @@ } }, "node_modules/isexe": { - "version": "3.1.1", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", + "integrity": "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "engines": { - "node": ">=16" + "node": ">=20" } }, "node_modules/lodash": { @@ -566,13 +502,6 @@ "dev": true, "license": "MIT" }, - "node_modules/lodash.zip": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/lodash.zip/-/lodash.zip-4.2.0.tgz", - "integrity": "sha512-C7IOaBBK/0gMORRBd8OETNx3kmOkgIWIPvyDpZSCTwUrpYmgZwJkjZeOD8ww4xbOUOs4/attY+pciKvadNfFbg==", - "dev": true, - "license": "MIT" - }, "node_modules/merge-stream": { "version": "2.0.0", "dev": true, @@ -607,19 +536,19 @@ } }, "node_modules/nopt": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-8.1.0.tgz", - "integrity": "sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A==", + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-9.0.0.tgz", + "integrity": "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==", "dev": true, "license": "ISC", "dependencies": { - "abbrev": "^3.0.0" + "abbrev": "^4.0.0" }, "bin": { "nopt": "bin/nopt.js" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm-run-path": { @@ -652,26 +581,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/p-finally": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", - "integrity": "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/p-try": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", - "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/path-key": { "version": "3.1.1", "dev": true, @@ -693,16 +602,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/pify": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", - "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/plist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.1.tgz", @@ -728,18 +627,6 @@ "node": ">= 0.3.1" } }, - "node_modules/q": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/q/-/q-1.5.1.tgz", - "integrity": "sha512-kV/CThkXo6xyFEZUugw/+pIOywXcDbFYgSct5cT3gqlbkBE1SJdwy6UQoZvodiWF/ckQLZyDE/Bu1M6gVu5lVw==", - "deprecated": "You or someone you depend on is using Q, the JavaScript Promise library that gave JavaScript developers strong feelings about promises. They can almost certainly migrate to the native JavaScript promise now. Thank you literally everyone for joining me in this bet against the odds. Be excellent to each other.\n\n(For a CapTP with native promises, see @endo/eventual-send and @endo/captp)", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.6.0", - "teleport": ">=0.2.0" - } - }, "node_modules/queue-microtask": { "version": "1.2.3", "dev": true, @@ -759,20 +646,6 @@ ], "license": "MIT" }, - "node_modules/read-chunk": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/read-chunk/-/read-chunk-3.2.0.tgz", - "integrity": "sha512-CEjy9LCzhmD7nUpJ1oVOE6s/hBkejlcJEgLQHVnQznOSilOPb+kpKktlLfFDK3/WP43+F80xkUTM2VOkYoSYvQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "pify": "^4.0.1", - "with-open-file": "^0.1.6" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/reusify": { "version": "1.1.0", "dev": true, @@ -810,7 +683,9 @@ "license": "ISC" }, "node_modules/semver": { - "version": "7.7.3", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, "license": "ISC", "bin": { @@ -899,16 +774,6 @@ "node": ">=0.6.19" } }, - "node_modules/strip-bom": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", - "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/strip-final-newline": { "version": "2.0.0", "dev": true, @@ -942,6 +807,7 @@ "version": "7.0.3", "resolved": "https://registry.npmjs.org/uuid/-/uuid-7.0.3.tgz", "integrity": "sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", "dev": true, "license": "MIT", "bin": { @@ -949,34 +815,19 @@ } }, "node_modules/which": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz", - "integrity": "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", + "integrity": "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==", "dev": true, "license": "ISC", "dependencies": { - "isexe": "^3.1.1" + "isexe": "^4.0.0" }, "bin": { "node-which": "bin/which.js" }, "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/with-open-file": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/with-open-file/-/with-open-file-0.1.7.tgz", - "integrity": "sha512-ecJS2/oHtESJ1t3ZfMI3B7KIDKyfN0O16miWxdn30zdh66Yd3LsRFebXZXq6GU4xfxLf6nVxp9kIqElb5fqczA==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-finally": "^1.0.0", - "p-try": "^2.1.0", - "pify": "^4.0.1" - }, - "engines": { - "node": ">=6" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/xcode": { diff --git a/purchasely/example/package.json b/purchasely/example/package.json index ef22df8..452ae70 100644 --- a/purchasely/example/package.json +++ b/purchasely/example/package.json @@ -15,8 +15,8 @@ "devDependencies": { "@purchasely/cordova-plugin-purchasely": "file:..", "@purchasely/cordova-plugin-purchasely-google": "file:../../purchasely-google", - "cordova-android": "^14.0.1", - "cordova-ios": "^8.0.0", + "cordova-android": "^15.0.0", + "cordova-ios": "^8.1.0", "cordova-plugin-purchasely": "file:..", "cordova-plugin-purchasely-google": "file:../../purchasely-google" }, @@ -29,8 +29,8 @@ "@purchasely/cordova-plugin-purchasely-google": {} }, "platforms": [ - "android", - "ios" + "ios", + "android" ] } -} +} \ No newline at end of file diff --git a/purchasely/example/www/index.html b/purchasely/example/www/index.html index ca01900..b905b12 100644 --- a/purchasely/example/www/index.html +++ b/purchasely/example/www/index.html @@ -54,17 +54,11 @@

Apache Cordova

- -
-
- +
-
- -
diff --git a/purchasely/example/www/js/index.js b/purchasely/example/www/js/index.js index 8d228cc..31a9046 100644 --- a/purchasely/example/www/js/index.js +++ b/purchasely/example/www/js/index.js @@ -41,12 +41,13 @@ function onDeviceReady() { document.getElementById('deviceready').classList.add('ready'); Purchasely.start( - 'fcb39be4-2ba4-4db7-bde3-2a5a1e20745d', - ['Google'], - false, - null, - Purchasely.LogLevel.DEBUG, - Purchasely.RunningMode.full, + { + apiKey: 'fcb39be4-2ba4-4db7-bde3-2a5a1e20745d', + stores: [Purchasely.Store.google], + storeKit1: false, + logLevel: Purchasely.LogLevel.DEBUG, + runningMode: Purchasely.RunningMode.full + }, (isConfigured) => { if(isConfigured) onPurchaselySdkReady(); }, @@ -60,14 +61,13 @@ function onDeviceReady() { document.getElementById("openPresentation").addEventListener("click", openPresentation); document.getElementById("fetchPresentation").addEventListener("click", fetchPresentation); - document.getElementById("presentSubscriptions").addEventListener("click", presentSubscriptions); - document.getElementById("showPresentation").addEventListener("click", showPresentation); - document.getElementById("hidePresentation").addEventListener("click", hidePresentation); + document.getElementById("backPresentation").addEventListener("click", backPresentation); document.getElementById("closePresentation").addEventListener("click", closePresentation); document.getElementById("purchaseWithPlanVendorId").addEventListener("click", purchaseWithPlanVendorId); document.getElementById("restore").addEventListener("click", restore); document.getElementById("silentRestore").addEventListener("click", silentRestore); document.getElementById("processToPayment").addEventListener("click", processToPayment); + document.getElementById("openDeeplink").addEventListener("click", openDeeplink); } @@ -139,7 +139,7 @@ function onPurchaselySdkReady() { Purchasely.setAttribute(Purchasely.Attribute.BATCH_CUSTOM_USER_ID, "batch_custom_user_id"); Purchasely.setAttribute(Purchasely.Attribute.BATCH_INSTALLATION_ID, "testBatch1"); - Purchasely.readyToOpenDeeplink(true); + Purchasely.allowDeeplink(true); Purchasely.planWithIdentifier('PURCHASELY_PLUS_MONTHLY', (plan) => { console.log(' ==> Plan'); @@ -157,11 +157,11 @@ function onPurchaselySdkReady() { console.log(error); }); - Purchasely.setDefaultPresentationResultHandler(callback => { + Purchasely.setDefaultPresentationDismissHandler(callback => { console.log(callback); if(callback.result == Purchasely.PurchaseResult.CANCELLED) { - console.log("User cancelled purchased"); - } else { + console.log("User cancelled purchase - close reason " + callback.closeReason); + } else if (callback.plan) { console.log("User purchased " + callback.plan.vendorId); } }, @@ -238,53 +238,58 @@ function onPurchaselySdkReady() { Purchasely.removeUserAttributeListener(); - Purchasely.setPaywallActionInterceptor((result) => { - console.log(result); - console.log('Received action from paywall ' + result.info.presentationId); - - if (result.action === Purchasely.PaywallAction.navigate) { - console.log( - 'User wants to navigate to website ' + - result.parameters.title + - ' ' + - result.parameters.url - ); - console.log('prevent Purchasely SDK to navigate to website'); - Purchasely.onProcessAction(true); - } else if (result.action === Purchasely.PaywallAction.close) { - console.log('User wants to close paywall - close reason ' + result.parameters.closeReason); - Purchasely.onProcessAction(true); - } else if (result.action === Purchasely.PaywallAction.close_all) { - console.log('User wants to close all paywalls'); - Purchasely.onProcessAction(true); - } else if (result.action === Purchasely.PaywallAction.login) { - console.log('User wants to login'); - //Present your own screen for user to log in - Purchasely.closePaywall(); - Purchasely.userLogin('MY_USER_ID'); - //Call this method to update Purchasely Paywall - Purchasely.onProcessAction(true); - } else if (result.action === Purchasely.PaywallAction.open_presentation) { - console.log('User wants to open a new paywall'); - Purchasely.onProcessAction(true); - } else if (result.action === Purchasely.PaywallAction.open_placement) { - console.log('User wants to open a new placement'); - Purchasely.onProcessAction(true); - } else if (result.action === Purchasely.PaywallAction.purchase) { - console.log('User wants to purchase'); - //If you want to intercept it, close paywall and display your screen - Purchasely.hidePresentation(); - } else if (result.action === Purchasely.PaywallAction.web_checkout) { - console.log('User wants to proceed to web checkout'); - console.log('web checkout url: ' + result.parameters.url); - console.log('web checkout provider: ' + result.parameters.webCheckoutProvider); - console.log('web checkout client reference id: ' + result.parameters.clientReferenceId); - console.log('web checkout query parameter key: ' + result.parameters.queryParameterKey); - Purchasely.onProcessAction(true); - } else { - console.log('Action unknown ' + result.action); - Purchasely.onProcessAction(true); - } + // Purchasely 6.0: per-action interceptor. Register a handler per action kind; each + // handler receives (info, parameters) and returns a Purchasely.InterceptResult telling + // the SDK how it was handled (notHandled = let the SDK proceed, success = app handled it). + Purchasely.removeAllActionInterceptors(); + + Purchasely.interceptAction(Purchasely.PresentationAction.navigate, (info, parameters) => { + console.log('User wants to navigate to website ' + parameters.title + ' ' + parameters.url); + console.log('let the Purchasely SDK navigate to website'); + return Purchasely.InterceptResult.notHandled; + }); + + Purchasely.interceptAction(Purchasely.PresentationAction.close, (info, parameters) => { + console.log('User wants to close paywall - close reason ' + parameters.closeReason); + return Purchasely.InterceptResult.notHandled; + }); + + Purchasely.interceptAction(Purchasely.PresentationAction.close_all, () => { + console.log('User wants to close all paywalls'); + return Purchasely.InterceptResult.notHandled; + }); + + Purchasely.interceptAction(Purchasely.PresentationAction.login, () => { + console.log('User wants to login'); + // Present your own screen for the user to log in, then report success so the paywall refreshes. + Purchasely.closePresentation(); + Purchasely.userLogin('MY_USER_ID'); + return Purchasely.InterceptResult.success; + }); + + Purchasely.interceptAction(Purchasely.PresentationAction.open_presentation, () => { + console.log('User wants to open a new paywall'); + return Purchasely.InterceptResult.notHandled; + }); + + Purchasely.interceptAction(Purchasely.PresentationAction.open_placement, () => { + console.log('User wants to open a new placement'); + return Purchasely.InterceptResult.notHandled; + }); + + Purchasely.interceptAction(Purchasely.PresentationAction.purchase, () => { + console.log('User wants to purchase'); + // Let the SDK proceed with the purchase. + return Purchasely.InterceptResult.notHandled; + }); + + Purchasely.interceptAction(Purchasely.PresentationAction.web_checkout, (info, parameters) => { + console.log('User wants to proceed to web checkout'); + console.log('web checkout url: ' + parameters.url); + console.log('web checkout provider: ' + parameters.webCheckoutProvider); + console.log('web checkout client reference id: ' + parameters.clientReferenceId); + console.log('web checkout query parameter key: ' + parameters.queryParameterKey); + return Purchasely.InterceptResult.notHandled; }); Purchasely.clearBuiltInAttributes(); @@ -294,7 +299,7 @@ function openPresentation() { Purchasely.presentPresentationForPlacement( 'ONBOARDING', //placementId null, //contentId - true, //fullscreen + Purchasely.TransitionType.fullScreen, //display mode (callback) => { console.log(callback); if(callback.result == Purchasely.PurchaseResult.CANCELLED) { @@ -315,7 +320,7 @@ function fetchPresentation() { null, //contentId (presentation) => { console.log(safeStringify(presentation)); - Purchasely.presentPresentation(presentation, false, null, + Purchasely.presentPresentation(presentation, Purchasely.TransitionType.modal, null, (callback) => { console.log(callback); if(callback.result == Purchasely.PurchaseResult.CANCELLED) { @@ -333,31 +338,22 @@ function fetchPresentation() { ); } -function presentSubscriptions() { - Purchasely.presentSubscriptions(); -} - function purchaseWithPlanVendorId() { Purchasely.purchaseWithPlanVendorId("PURCHASELY_PLUS_MONTHLY"); } function processToPayment() { - // Call this method open paywall again - Purchasely.showPresentation(); - - // Call this method in paywallObserver mode to synchronize purchases with Purchasely - // Purchasely.synchronize(); - - // Call this method to process to payment or false if you handled it - Purchasely.onProcessAction(true); -} + // Call this method in observer mode to synchronize purchases with Purchasely + // Purchasely.synchronize((ok) => console.log("synchronized " + ok), (e) => console.log(e)); -function showPresentation() { - Purchasely.showPresentation(); + // Purchasely 6.0: with the per-action interceptor (interceptAction), each handler returns + // its own Purchasely.InterceptResult, so there is no separate "process action" step. + // Kept as a no-op for the example's existing button wiring. + console.log('processToPayment: no-op in v6 — interceptAction handlers report their own result'); } -function hidePresentation() { - Purchasely.hidePresentation(); +function backPresentation() { + Purchasely.backPresentation(); } function closePresentation() { @@ -416,7 +412,7 @@ function signPromotionalOffer() { } function openDeeplink() { - Purchasely.isDeeplinkHandled( + Purchasely.handleDeeplink( "purchasely://ply/presentations/CAROUSEL", isHandled => { console.log("Deeplink is handled ? " + isHandled) diff --git a/purchasely/package-lock.json b/purchasely/package-lock.json index a8be305..b8f8cb0 100644 --- a/purchasely/package-lock.json +++ b/purchasely/package-lock.json @@ -1,12 +1,12 @@ { "name": "@purchasely/cordova-plugin-purchasely", - "version": "5.7.3", + "version": "6.0.0-rc.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@purchasely/cordova-plugin-purchasely", - "version": "5.7.3", + "version": "6.0.0-rc.1", "license": "ISC", "devDependencies": { "jest": "^29.7.0" diff --git a/purchasely/package.json b/purchasely/package.json index 4438ea2..64e7968 100644 --- a/purchasely/package.json +++ b/purchasely/package.json @@ -1,6 +1,6 @@ { "name": "@purchasely/cordova-plugin-purchasely", - "version": "5.7.3", + "version": "6.0.0-rc.1", "description": "Purchasely is a solution to ease the integration and boost your In-App Purchases & Subscriptions on the App Store, Google Play Store, Amazon Appstore and Huawei App Gallery.", "cordova": { "id": "@purchasely/cordova-plugin-purchasely", diff --git a/purchasely/plugin.xml b/purchasely/plugin.xml index f773591..b767408 100644 --- a/purchasely/plugin.xml +++ b/purchasely/plugin.xml @@ -1,5 +1,5 @@ - + Purchasely @@ -38,7 +38,7 @@ - +
@@ -51,27 +51,8 @@ - - - - - - - + - - - - - - -
diff --git a/purchasely/src/android/PLYProductActivity.kt b/purchasely/src/android/PLYProductActivity.kt deleted file mode 100644 index af53d3e..0000000 --- a/purchasely/src/android/PLYProductActivity.kt +++ /dev/null @@ -1,168 +0,0 @@ -package cordova.plugin.purchasely - -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 cordova.plugin.purchasely.PurchaselyPlugin.ProductActivity -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 - -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); - } - - val package_name = application.packageName - - setContentView( - application.resources - .getIdentifier( - "activity_ply_product_activity", - "layout", - package_name - ) - ) - - val container = findViewById( - resources.getIdentifier( - "container", - "id", - package_name - ) - ) as FrameLayout - - try { - val loadingBackgroundColor = backgroundColor.parseColor(Color.WHITE) - 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 = { - 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) { - container.background = backgroundPaywall - } - }, - onClose = { - container.removeAllViews() - supportFinishAfterTransition() - } - ), - callback = callback - ) - } - - if(paywallView == null) { - finish() - return - } - - container.addView(paywallView) - } - - override fun onStart() { - super.onStart() - - val productActivity = ProductActivity() - productActivity.presentation = presentation - productActivity.presentationId = presentationId - productActivity.placementId = placementId - productActivity.productId = productId - productActivity.planId = planId - productActivity.contentId = contentId - productActivity.isFullScreen = isFullScreen - productActivity.loadingBackgroundColor = backgroundColor - productActivity.activity = WeakReference(this) - PurchaselyPlugin.productActivity = productActivity - } - - private val callback: (PLYProductViewResult, PLYPlan?) -> Unit = { result, plan -> - PurchaselyPlugin.sendPurchaseResult( - result, - plan - ) - //supportFinishAfterTransition() - } - - companion object { - @JvmStatic - fun newIntent(activity: Activity?, - properties: PLYPresentationProperties = PLYPresentationProperties(), - isFullScreen: Boolean = false, - backgroundColor: String? = null) = Intent(activity, PLYProductActivity::class.java).apply { - //remove old activity if still referenced to avoid issues - val oldActivity = PurchaselyPlugin.productActivity?.activity?.get() - oldActivity?.finish() - PurchaselyPlugin.productActivity?.activity = null - PurchaselyPlugin.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) - } - } - -} \ No newline at end of file diff --git a/purchasely/src/android/PLYSubscriptionsActivity.java b/purchasely/src/android/PLYSubscriptionsActivity.java deleted file mode 100644 index 62e8ee2..0000000 --- a/purchasely/src/android/PLYSubscriptionsActivity.java +++ /dev/null @@ -1,37 +0,0 @@ -package cordova.plugin.purchasely; - -import android.os.Bundle; - -import androidx.annotation.Nullable; -import androidx.appcompat.app.AppCompatActivity; - -import io.purchasely.ext.Purchasely; - -public class PLYSubscriptionsActivity extends AppCompatActivity { - - @Override - protected void onCreate(@Nullable @org.jetbrains.annotations.Nullable Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - String package_name = getApplication().getPackageName(); - setContentView(getApplication().getResources() - .getIdentifier("activity_ply_subscriptions_activity", - "layout", - package_name) - ); - - getSupportFragmentManager() - .beginTransaction() - .addToBackStack(null) - .replace(getApplication().getResources() - .getIdentifier("fragmentContainer", - "id", - package_name), Purchasely.subscriptionsFragment(), "SubscriptionsFragment") - .commitAllowingStateLoss(); - - getSupportFragmentManager().addOnBackStackChangedListener(() -> { - if(getSupportFragmentManager().getBackStackEntryCount() == 0) { - supportFinishAfterTransition(); - } - }); - } -} diff --git a/purchasely/src/android/PurchaselyPlugin.kt b/purchasely/src/android/PurchaselyPlugin.kt index 44e23f5..a86d974 100644 --- a/purchasely/src/android/PurchaselyPlugin.kt +++ b/purchasely/src/android/PurchaselyPlugin.kt @@ -1,7 +1,5 @@ package cordova.plugin.purchasely -import android.app.Activity -import android.content.Intent import android.net.Uri import android.os.Build import android.util.Log @@ -10,28 +8,24 @@ import io.purchasely.ext.Attribute import io.purchasely.ext.DistributionType import io.purchasely.ext.EventListener import io.purchasely.ext.LogLevel +import io.purchasely.ext.PLYActionInterceptorCallback import io.purchasely.ext.PLYAppTechnology -import io.purchasely.ext.PLYCompletionHandler import io.purchasely.ext.PLYDataProcessingLegalBasis import io.purchasely.ext.PLYDataProcessingPurpose import io.purchasely.ext.PLYEvent -import io.purchasely.ext.PLYPresentation -import io.purchasely.ext.PLYPresentationAction -import io.purchasely.ext.PLYPresentationType -import io.purchasely.ext.PLYPresentationProperties -import io.purchasely.ext.PLYProductViewResult +import io.purchasely.ext.PLYInterceptResult +import io.purchasely.ext.PLYInterceptorInfo import io.purchasely.ext.PLYRunningMode -import io.purchasely.ext.PLYRunningMode.Full -import io.purchasely.ext.PLYRunningMode.PaywallObserver -import io.purchasely.ext.PlanListener -import io.purchasely.ext.ProductListener -import io.purchasely.ext.ProductsListener import io.purchasely.ext.PurchaseListener import io.purchasely.ext.Purchasely import io.purchasely.ext.State import io.purchasely.ext.StoreType -import io.purchasely.ext.SubscriptionsListener import io.purchasely.ext.UserAttributeListener +import io.purchasely.ext.presentation.PLYPresentationAction +import io.purchasely.ext.presentation.PLYPresentationBase +import io.purchasely.ext.presentation.PLYPresentationOutcome +import io.purchasely.ext.presentation.display +import io.purchasely.ext.presentation.preload import io.purchasely.storage.userData.PLYUserAttributeSource import io.purchasely.storage.userData.PLYUserAttributeType import io.purchasely.models.PLYError @@ -40,26 +34,50 @@ import io.purchasely.models.PLYPresentationPlan import io.purchasely.models.PLYProduct import io.purchasely.models.PLYSubscriptionData import io.purchasely.views.presentation.PLYThemeMode +import io.purchasely.views.presentation.models.PLYTransition +import io.purchasely.views.presentation.models.PLYTransitionType +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch import org.apache.cordova.CallbackContext -import org.apache.cordova.CordovaInterface import org.apache.cordova.CordovaPlugin import org.apache.cordova.PluginResult import org.json.JSONArray import org.json.JSONException import org.json.JSONObject -import java.lang.ref.WeakReference import java.text.SimpleDateFormat import java.util.Calendar import java.util.Date import java.util.Locale import java.util.TimeZone +import java.util.concurrent.ConcurrentHashMap /** * This class echoes a string called from JavaScript. */ -class PurchaselyPlugin : CordovaPlugin() { - private var paywallActionHandler: PLYCompletionHandler? = null - private var paywallAction: PLYPresentationAction? = null +class PurchaselyPlugin : CordovaPlugin(), CoroutineScope { + + private val job = SupervisorJob() + override val coroutineContext = job + Dispatchers.Main + + // v6 per-action interceptor state. Each registered action kind keeps its own + // Cordova callback (to emit intercept events); each intercepted invocation stashes + // its completion under a unique id so concurrent intercepts resolve independently. + private val actionInterceptorCallbacks = ConcurrentHashMap() + private val pendingInterceptCompletions = ConcurrentHashMap Unit>() + private var interceptorInvocationCounter = 0 + + // Presentation currently displayed (used for back()/close()). + private var displayedPresentation: PLYPresentationBase.Loaded? = null + // Presentations preloaded by fetchPresentation, keyed by a synthetic handle + // (the "id" we send back to JS) so presentPresentation can re-display them. + private val loadedPresentations = ConcurrentHashMap() + + override fun onDestroy() { + job.cancel() + super.onDestroy() + } override fun execute( action: String, @@ -68,16 +86,7 @@ class PurchaselyPlugin : CordovaPlugin() { ): Boolean { try { when (action) { - "start" -> start( - getStringFromJson(args.getString(0)), - args.getJSONArray(1), - args.getBoolean(2), - getStringFromJson(args.getString(3)), - args.getInt(4), - args.getInt(5), - getStringFromJson(args.getString(6)), - callbackContext - ) + "start" -> start(args.getJSONObject(0), callbackContext) "close" -> close() "addEventsListener" -> addEventsListener(callbackContext) @@ -91,42 +100,28 @@ class PurchaselyPlugin : CordovaPlugin() { "setLogLevel" -> setLogLevel(args.getInt(0)) "setThemeMode" -> setThemeMode(args.getInt(0)) "setAttribute" -> setAttribute(args.getInt(0), getStringFromJson(args.getString(1))) - "setDefaultPresentationResultHandler" -> setDefaultPresentationResultHandler( + "setDefaultPresentationDismissHandler" -> setDefaultPresentationDismissHandler( callbackContext ) "purchasedSubscription" -> purchasedSubscription(callbackContext) - "readyToOpenDeeplink" -> readyToOpenDeeplink(args.getBoolean(0)) - "synchronize" -> synchronize() + "allowDeeplink" -> allowDeeplink(args.getBoolean(0)) + "allowCampaigns" -> allowCampaigns(args.getBoolean(0)) + "synchronize" -> synchronize(callbackContext) "presentPresentationWithIdentifier" -> presentPresentationWithIdentifier( getStringFromJson(args.getString(0)), getStringFromJson(args.getString(1)), - args.getBoolean(2), + getStringFromJson(args.getString(2)), callbackContext ) "presentPresentationForPlacement" -> presentPresentationForPlacement( - getStringFromJson(args.getString(0)), - getStringFromJson(args.getString(1)), - args.getBoolean(2), - callbackContext - ) - - "presentProductWithIdentifier" -> presentProductWithIdentifier( getStringFromJson(args.getString(0)), getStringFromJson(args.getString(1)), getStringFromJson(args.getString(2)), - args.getBoolean(3), callbackContext ) - "presentPlanWithIdentifier" -> presentPlanWithIdentifier( - getStringFromJson(args.getString(0)), - getStringFromJson(args.getString(1)), - getStringFromJson(args.getString(2)), - args.getBoolean(3), - callbackContext - ) "fetchPresentation" -> fetchPresentation( getStringFromJson(args.getString(0)), getStringFromJson(args.getString(1)), @@ -135,16 +130,15 @@ class PurchaselyPlugin : CordovaPlugin() { ) "presentPresentation" -> presentPresentation( args.getJSONObject(0), - args.getBoolean(1), + getStringFromJson(args.getString(1)), getStringFromJson(args.getString(2)), callbackContext ) - "presentSubscriptions" -> presentSubscriptions() "restoreAllProducts" -> restoreAllProducts(callbackContext) "silentRestoreAllProducts" -> restoreAllProducts(callbackContext) "userSubscriptions" -> userSubscriptions(callbackContext) "userSubscriptionsHistory" -> userSubscriptionsHistory(callbackContext) - "isDeeplinkHandled" -> isDeeplinkHandled( + "handleDeeplink" -> handleDeeplink( getStringFromJson(args.getString(0)), callbackContext ) @@ -166,11 +160,11 @@ class PurchaselyPlugin : CordovaPlugin() { getStringFromJson(args.getString(2)), callbackContext ) - "setPaywallActionInterceptor" -> setPaywallActionInterceptor(callbackContext) - "onProcessAction" -> onProcessAction(args.getBoolean(0)) + "registerActionInterceptor" -> registerActionInterceptor(getStringFromJson(args.getString(0)), callbackContext) + "unregisterActionInterceptor" -> unregisterActionInterceptor(getStringFromJson(args.getString(0))) + "completeActionInterceptor" -> completeActionInterceptor(getStringFromJson(args.getString(0)), getStringFromJson(args.getString(1))) "closePresentation" -> closePresentation(callbackContext) - "hidePresentation" -> hidePresentation() - "showPresentation" -> showPresentation() + "backPresentation" -> backPresentation(callbackContext) "userDidConsumeSubscriptionContent" -> userDidConsumeSubscriptionContent() "setUserAttributeWithString" -> setUserAttributeWithString(getStringFromJson(args.getString(0)), getStringFromJson(args.getString(1)), getStringFromJson(args.optString(2))) "setUserAttributeWithBoolean" -> setUserAttributeWithBoolean(getStringFromJson(args.getString(0)), args.getBoolean(1), getStringFromJson(args.optString(2))) @@ -203,52 +197,83 @@ class PurchaselyPlugin : CordovaPlugin() { } else value } - private fun start( - apiKey: String?, - stores: JSONArray, - storeKit1: Boolean, - userId: String?, - logLevel: Int, - runningMode: Int, - cordovaSdkVersion: String?, - callbackContext: CallbackContext - ) { + //region start + private fun logLevelFrom(raw: Any?): LogLevel { + return when (raw) { + is Number -> LogLevel.values().getOrElse(raw.toInt()) { LogLevel.ERROR } + is String -> LogLevel.values().firstOrNull { it.name.equals(raw, ignoreCase = true) } ?: LogLevel.ERROR + else -> LogLevel.ERROR + } + } - if(apiKey == null) { + // v6: running mode is passed by NAME ("observer"/"full"). The old int mapping is + // gone; PLYRunningMode has only Observer (default) and Full. + private fun runningModeFrom(raw: Any?): PLYRunningMode { + return when (raw) { + is Number -> when (raw.toInt()) { + 1 -> PLYRunningMode.Full + else -> PLYRunningMode.Observer + } + is String -> when (raw.lowercase(Locale.US)) { + "full" -> PLYRunningMode.Full + else -> PLYRunningMode.Observer + } + else -> PLYRunningMode.Observer + } + } + + private fun start(options: JSONObject, callbackContext: CallbackContext) { + val apiKey = getStringFromJson(options.optString("apiKey")) + if (apiKey == null) { callbackContext.error("API Key is null") return } - val list = ArrayList() - for (i in 0 until stores.length()) { - try { - list.add(stores.getString(i)) - } catch (e: JSONException) { - Log.e("Purchasely", "Error in store array" + e.message, e) + val userId = getStringFromJson(options.optString("appUserId")) + val logLevel = logLevelFrom(options.opt("logLevel")) + val runningMode = runningModeFrom(options.opt("runningMode")) + + val storesList = ArrayList() + options.optJSONArray("stores")?.let { arr -> + for (i in 0 until arr.length()) { + try { + storesList.add(arr.getString(i)) + } catch (e: JSONException) { + Log.e("Purchasely", "Error in store array" + e.message, e) + } } } - val storesInstances = getStoresInstances(list) - var plyRunningMode: PLYRunningMode = Full - if (runningMode == runningModePaywallObserver) plyRunningMode = PaywallObserver + + val allowDeeplink = if (options.has("allowDeeplink")) options.optBoolean("allowDeeplink") else null + val allowCampaigns = if (options.has("allowCampaigns")) options.optBoolean("allowCampaigns") else null + val deeplink = getStringFromJson(options.optString("deeplink")) + val sdkVersion = getStringFromJson(options.optString("sdkVersion")) + Purchasely.Builder(cordova.context) .apiKey(apiKey) - .stores(storesInstances) + .stores(getStoresInstances(storesList)) .userId(userId) - .runningMode(plyRunningMode) - .logLevel(LogLevel.values()[logLevel]) + .runningMode(runningMode) + .logLevel(logLevel) + .apply { + allowDeeplink?.let { this.allowDeeplink(it) } + allowCampaigns?.let { this.allowCampaigns(it) } + // Cold-start deeplink: replayed automatically once started. + deeplink?.let { this.handleDeeplink(Uri.parse(it)) } + } .build() - Purchasely.sdkBridgeVersion = cordovaSdkVersion + + Purchasely.sdkBridgeVersion = sdkVersion Purchasely.appTechnology = PLYAppTechnology.CORDOVA - Purchasely.start { isConfigured: Boolean, error: PLYError? -> - if (isConfigured) { - callbackContext.success() + Purchasely.start { error: PLYError? -> + if (error == null) { + callbackContext.sendPluginResult(PluginResult(PluginResult.Status.OK, true)) } else { - callbackContext.error( - if (error != null) error.message else "Purchasely SDK not configured" - ) + callbackContext.error(error.message ?: "Purchasely SDK not configured") } } } + //endregion private fun getStoresInstances(stores: List): ArrayList { val result = ArrayList() @@ -279,9 +304,12 @@ class PurchaselyPlugin : CordovaPlugin() { private fun close() { defaultCallback = null purchaseCallback = null - paywallActionHandler = null - productActivity = null - Purchasely.close() + actionInterceptorCallbacks.clear() + pendingInterceptCompletions.clear() + displayedPresentation = null + // TODO(v6-verify): the global Purchasely.close() teardown was removed/lumped with + // presentation close in v6 (Flutter never calls it). Not invoked here to avoid a + // reference to a symbol that may no longer exist. } private fun addUserAttributesListener(callbackContext: CallbackContext) { @@ -358,7 +386,7 @@ class PurchaselyPlugin : CordovaPlugin() { } private fun userLogout() { - Purchasely.userLogout() + Purchasely.userLogout(true) } private fun setLogLevel(logLevel: Int) { @@ -373,8 +401,12 @@ class PurchaselyPlugin : CordovaPlugin() { } } - private fun readyToOpenDeeplink(isReadyToPurchase: Boolean) { - Purchasely.readyToOpenDeeplink = isReadyToPurchase + private fun allowDeeplink(allow: Boolean) { + Purchasely.allowDeeplink = allow + } + + private fun allowCampaigns(allow: Boolean) { + Purchasely.allowCampaigns = allow } private fun setThemeMode(mode: Int) { @@ -414,15 +446,16 @@ class PurchaselyPlugin : CordovaPlugin() { } } - private fun setDefaultPresentationResultHandler(callbackContext: CallbackContext) { + //region Default presentation dismiss handler + private fun setDefaultPresentationDismissHandler(callbackContext: CallbackContext) { defaultCallback = callbackContext - Purchasely.setDefaultPresentationResultHandler { result: PLYProductViewResult, plan: PLYPlan? -> - sendPurchaseResult( - result, - plan - ) + Purchasely.setDefaultPresentationDismissHandler { outcome: PLYPresentationOutcome -> + val pluginResult = PluginResult(PluginResult.Status.OK, JSONObject(outcomeToMap(outcome))) + pluginResult.keepCallback = true + defaultCallback?.sendPluginResult(pluginResult) } } + //endregion private fun purchasedSubscription(callbackContext: CallbackContext) { Purchasely.purchaseListener = object : PurchaseListener { @@ -437,72 +470,82 @@ class PurchaselyPlugin : CordovaPlugin() { } } - private fun synchronize() { - Purchasely.synchronize() + private fun synchronize(callbackContext: CallbackContext) { + Purchasely.synchronize( + onSuccess = { + callbackContext.sendPluginResult(PluginResult(PluginResult.Status.OK, true)) + }, + onError = { error -> + callbackContext.error(error?.message ?: "Synchronization failed") + } + ) } private fun userDidConsumeSubscriptionContent() { Purchasely.userDidConsumeSubscriptionContent() } + //region Presentation lifecycle + // v6: the presentation activities are gone. A presentation is built via the + // builder DSL, preloaded, then displayed. `displayMode` maps to a PLYTransition. private fun presentPresentationWithIdentifier( presentationVendorId: String?, contentId: String?, - isFullScreen: Boolean, + displayMode: String?, callbackContext: CallbackContext ) { purchaseCallback = callbackContext - val intent = PLYProductActivity.newIntent(cordova.activity) - intent.putExtra("presentationId", presentationVendorId) - intent.putExtra("contentId", contentId) - intent.putExtra("isFullScreen", isFullScreen) - cordova.activity.startActivity(intent) + displayPresentation( + screenId = presentationVendorId, + placementId = null, + contentId = contentId, + displayMode = displayMode, + callbackContext = callbackContext + ) } private fun presentPresentationForPlacement( placementVendorId: String?, contentId: String?, - isFullScreen: Boolean, + displayMode: String?, callbackContext: CallbackContext ) { purchaseCallback = callbackContext - val intent = PLYProductActivity.newIntent(cordova.activity) - intent.putExtra("placementId", placementVendorId) - intent.putExtra("contentId", contentId) - intent.putExtra("isFullScreen", isFullScreen) - cordova.activity.startActivity(intent) - } - - private fun presentProductWithIdentifier( - productVendorId: String?, - presentationVendorId: String?, - contentId: String?, - isFullScreen: Boolean, - callbackContext: CallbackContext - ) { - purchaseCallback = callbackContext - val intent = PLYProductActivity.newIntent(cordova.activity) - intent.putExtra("presentationId", presentationVendorId) - intent.putExtra("productId", productVendorId) - intent.putExtra("contentId", contentId) - intent.putExtra("isFullScreen", isFullScreen) - cordova.activity.startActivity(intent) + displayPresentation( + screenId = null, + placementId = placementVendorId, + contentId = contentId, + displayMode = displayMode, + callbackContext = callbackContext + ) } - private fun presentPlanWithIdentifier( - planVendorId: String?, - presentationVendorId: String?, + private fun displayPresentation( + screenId: String?, + placementId: String?, contentId: String?, - isFullScreen: Boolean, + displayMode: String?, callbackContext: CallbackContext ) { - purchaseCallback = callbackContext - val intent = PLYProductActivity.newIntent(cordova.activity) - intent.putExtra("presentationId", presentationVendorId) - intent.putExtra("planId", planVendorId) - intent.putExtra("contentId", contentId) - intent.putExtra("isFullScreen", isFullScreen) - cordova.activity.startActivity(intent) + launch { + try { + val builder = PLYPresentationBase.builder().apply { + when { + placementId != null -> this.placementId(placementId) + screenId != null -> this.screenId(screenId) + } + contentId(contentId) + } + val loaded = builder.build().preload() + displayedPresentation = loaded + loaded.display(cordova.activity, transitionForDisplayMode(displayMode)) { outcome -> + sendPurchaseResult(outcome) + } + } catch (t: Throwable) { + if (purchaseCallback === callbackContext) purchaseCallback = null + callbackContext.error(t.message ?: "Unable to present presentation") + } + } } private fun fetchPresentation( @@ -510,43 +553,30 @@ class PurchaselyPlugin : CordovaPlugin() { presentationId: String?, contentId: String?, callbackContext: CallbackContext) { - val properties = PLYPresentationProperties( - placementId = placementId, - presentationId = presentationId, - contentId = contentId) - - Purchasely.fetchPresentation(properties = properties) { presentation: PLYPresentation?, error: PLYError? -> - if(presentation != null) { - presentationsLoaded.removeAll { it.id == presentation.id && it.placementId == presentation.placementId } - presentationsLoaded.add(presentation) - val map = presentation.toMap().mapValues { - val value = it.value - if(value is PLYPresentationType) value.ordinal - else value - } - - val mutableMap = map.toMutableMap().apply { - //this["metadata"] = presentation.metadata?.toMap() - this["plans"] = (this["plans"] as List).map { it.toMap() } + launch { + try { + val builder = PLYPresentationBase.builder().apply { + when { + placementId != null -> this.placementId(placementId) + presentationId != null -> this.screenId(presentationId) + } + contentId(contentId) } + val loaded = builder.build().preload() + // Synthetic handle so presentPresentation can re-display this preloaded presentation. + val handle = "ply_fetch_${System.nanoTime()}" + loadedPresentations[handle] = loaded + val map = presentationToMap(loaded).toMutableMap() + map["id"] = handle callbackContext.success(JSONObject(map)) + } catch (t: Throwable) { + callbackContext.error(t.message ?: "Unable to fetch presentation") } - if(error != null) callbackContext.error(error.message ?: "Unable to fetch presentation") } } - // Delete when available in Android SDK - fun PLYPresentationPlan.toMap() : Map { - return mapOf( - Pair("planVendorId", planVendorId), - Pair("storeProductId", storeProductId), - Pair("basePlanId", basePlanId), - //Pair("offerId", offerId) - ) - } - private fun presentPresentation(presentationMap: JSONObject?, - isFullScreen: Boolean, + displayMode: String?, loadingBackgroundColor: String?, callbackContext: CallbackContext) { if (presentationMap == null) { @@ -554,112 +584,105 @@ class PurchaselyPlugin : CordovaPlugin() { 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) } } + } + + // Maps a JS display-mode string to a v6 PLYTransition. Null → surface default (full screen). + private fun transitionForDisplayMode(mode: String?): PLYTransition? { + val type = when (mode) { + "fullScreen" -> PLYTransitionType.FULLSCREEN + "push" -> PLYTransitionType.PUSH + "modal" -> PLYTransitionType.MODAL + "drawer" -> PLYTransitionType.DRAWER + "popin" -> PLYTransitionType.POPIN + "inlinePaywall" -> PLYTransitionType.INLINE_PAYWALL + else -> return null + } + // TODO(v6-verify): drawer/popin sizing (PLYTransitionDimension) is not derivable from a + // plain display-mode string, so width/height default to null (surface default / hug). + return PLYTransition(type = type, width = null, height = null, dismissible = true) + } + private fun closePresentation(callbackContext: CallbackContext) { + Purchasely.closeAllScreens() + displayedPresentation = null + callbackContext.success() } - private fun presentSubscriptions() { - val intent = - Intent(cordova.context, PLYSubscriptionsActivity::class.java) - cordova.activity.startActivity(intent) + private fun backPresentation(callbackContext: CallbackContext) { + cordova.activity?.runOnUiThread { displayedPresentation?.back() } + callbackContext.success() } + //endregion private fun restoreAllProducts(callbackContext: CallbackContext) { - Purchasely.restoreAllProducts({ plyPlan: PLYPlan? -> - callbackContext.success(JSONObject(transformPlanToMap(plyPlan))) - }) { plyError: PLYError? -> - callbackContext.error(plyError?.message) - } + Purchasely.restoreAllProducts( + onSuccess = { + callbackContext.success() + }, + onError = { plyError: PLYError? -> + callbackContext.error(plyError?.message) + } + ) } private fun userSubscriptions(callbackContext: CallbackContext) { - Purchasely.userSubscriptions( - false, - object : SubscriptionsListener { - override fun onSuccess(list: List) { - val result = JSONArray() - for (i in list.indices) { - val data = list[i] - val map = HashMap(data.toMap()) - map["plan"] = transformPlanToMap(data.plan) - map["product"] = data.product.toMap() - if (data.data.storeType == StoreType.GOOGLE_PLAY_STORE) { - map["subscriptionSource"] = StoreType.GOOGLE_PLAY_STORE.ordinal - } else if (data.data.storeType == StoreType.AMAZON_APP_STORE) { - map["subscriptionSource"] = StoreType.AMAZON_APP_STORE.ordinal - } else if (data.data.storeType == StoreType.HUAWEI_APP_GALLERY) { - map["subscriptionSource"] = StoreType.HUAWEI_APP_GALLERY.ordinal - } else if (data.data.storeType == StoreType.APPLE_APP_STORE) { - map["subscriptionSource"] = StoreType.APPLE_APP_STORE.ordinal - } - result.put(JSONObject(map)) - } - callbackContext.success(result) - } - - override fun onFailure(throwable: Throwable) { - callbackContext.error(throwable.message) - } + launch { + try { + val list = Purchasely.userSubscriptions(true) + callbackContext.success(transformSubscriptionsToJson(list)) + } catch (e: Exception) { + callbackContext.error(e.message) } - ) + } } private fun userSubscriptionsHistory(callbackContext: CallbackContext) { - Purchasely.userSubscriptionsHistory( - false, - object : SubscriptionsListener { - override fun onSuccess(list: List) { - val result = JSONArray() - for (i in list.indices) { - val data = list[i] - val map = HashMap(data.toMap()) - map["plan"] = transformPlanToMap(data.plan) - map["product"] = data.product.toMap() - if (data.data.storeType == StoreType.GOOGLE_PLAY_STORE) { - map["subscriptionSource"] = StoreType.GOOGLE_PLAY_STORE.ordinal - } else if (data.data.storeType == StoreType.AMAZON_APP_STORE) { - map["subscriptionSource"] = StoreType.AMAZON_APP_STORE.ordinal - } else if (data.data.storeType == StoreType.HUAWEI_APP_GALLERY) { - map["subscriptionSource"] = StoreType.HUAWEI_APP_GALLERY.ordinal - } else if (data.data.storeType == StoreType.APPLE_APP_STORE) { - map["subscriptionSource"] = StoreType.APPLE_APP_STORE.ordinal - } - result.put(JSONObject(map)) - } - callbackContext.success(result) - } + launch { + try { + val list = Purchasely.userSubscriptionsHistory(true) + callbackContext.success(transformSubscriptionsToJson(list)) + } catch (e: Exception) { + callbackContext.error(e.message) + } + } + } - override fun onFailure(throwable: Throwable) { - callbackContext.error(throwable.message) - } + private fun transformSubscriptionsToJson(list: List): JSONArray { + val result = JSONArray() + for (data in list) { + val map = HashMap(data.data.toMap()) + map["plan"] = transformPlanToMap(data.plan) + map["product"] = data.product.toMap() + map["subscriptionSource"] = when (data.data.storeType) { + StoreType.GOOGLE_PLAY_STORE -> StoreType.GOOGLE_PLAY_STORE.ordinal + StoreType.AMAZON_APP_STORE -> StoreType.AMAZON_APP_STORE.ordinal + StoreType.HUAWEI_APP_GALLERY -> StoreType.HUAWEI_APP_GALLERY.ordinal + StoreType.APPLE_APP_STORE -> StoreType.APPLE_APP_STORE.ordinal + else -> null } - ) + result.put(JSONObject(map)) + } + return result } - private fun isDeeplinkHandled(deeplink: String?, callbackContext: CallbackContext) { + private fun handleDeeplink(deeplink: String?, callbackContext: CallbackContext) { if (deeplink == null) { callbackContext.error("Deeplink must not be null") return @@ -668,25 +691,24 @@ class PurchaselyPlugin : CordovaPlugin() { callbackContext.sendPluginResult( PluginResult( PluginResult.Status.OK, - Purchasely.isDeeplinkHandled(uri) + Purchasely.handleDeeplink(uri, cordova.activity) ) ) } private fun allProducts(callbackContext: CallbackContext) { - Purchasely.allProducts(object : ProductsListener { - override fun onSuccess(list: List) { + launch { + try { + val list = Purchasely.allProducts() val result = JSONArray() - for (i in list.indices) { - result.put(JSONObject(list[i].toMap())) + for (product in list) { + result.put(JSONObject(product.toMap())) } callbackContext.success(result) + } catch (e: Exception) { + callbackContext.error(e.message) } - - override fun onFailure(throwable: Throwable) { - callbackContext.error(throwable.message) - } - }) + } } private fun productWithIdentifier(vendorId: String?, callbackContext: CallbackContext) { @@ -694,19 +716,18 @@ class PurchaselyPlugin : CordovaPlugin() { callbackContext.error("No product found with $vendorId") return } - Purchasely.product(vendorId, object : ProductListener { - override fun onSuccess(product: PLYProduct?) { + launch { + try { + val product: PLYProduct? = Purchasely.product(vendorId) if (product != null) { callbackContext.success(JSONObject(product.toMap())) } else { callbackContext.error("No product found with $vendorId") } + } catch (e: Exception) { + callbackContext.error(e.message) } - - override fun onFailure(throwable: Throwable) { - callbackContext.error(throwable.message) - } - }) + } } private fun planWithIdentifier(vendorId: String?, callbackContext: CallbackContext) { @@ -714,19 +735,18 @@ class PurchaselyPlugin : CordovaPlugin() { callbackContext.error("No plan found with $vendorId") return } - Purchasely.plan(vendorId, object : PlanListener { - override fun onSuccess(plan: PLYPlan?) { + launch { + try { + val plan: PLYPlan? = Purchasely.plan(vendorId) if (plan != null) { callbackContext.success(JSONObject(transformPlanToMap(plan))) } else { callbackContext.error("No plan found with $vendorId") } + } catch (e: Exception) { + callbackContext.error(e.message) } - - override fun onFailure(throwable: Throwable) { - callbackContext.error(throwable.message) - } - }) + } } private fun purchaseWithPlanVendorId( @@ -740,114 +760,129 @@ class PurchaselyPlugin : CordovaPlugin() { return } - Purchasely.plan(planVendorId, object : PlanListener { - override fun onSuccess(plyPlan: PLYPlan?) { + launch { + try { + val plyPlan: PLYPlan? = Purchasely.plan(planVendorId) if (plyPlan != null) { val offer = plyPlan.promoOffers.firstOrNull { it.vendorId == offerId } Purchasely.purchase(cordova.activity, plyPlan, offer, contentId, - { plyPlan1: PLYPlan? -> + onSuccess = { plyPlan1: PLYPlan? -> callbackContext.success(JSONObject(transformPlanToMap(plyPlan1))) - }) { plyError: PLYError? -> - callbackContext.error(plyError?.message) - } + }, + onError = { plyError: PLYError? -> + callbackContext.error(plyError?.message) + }) } else { callbackContext.error("No plan found with $planVendorId") } + } catch (e: Exception) { + callbackContext.error(e.message) } + } + } - override fun onFailure(throwable: Throwable) { - callbackContext.error(throwable.message) + //region Action interceptor + // v6: register a JS handler for a single action kind. Each intercept emits an + // event carrying a unique callbackId; JS replies via completeActionInterceptor. + private fun registerActionInterceptor(kind: String?, callbackContext: CallbackContext) { + if (kind == null) return + val clazz = PLYPresentationAction.fromValue(kind)?.java + if (clazz == null) { + Log.w("Purchasely", "Unknown interceptor kind: $kind") + return + } + actionInterceptorCallbacks[kind] = callbackContext + Purchasely.interceptAction(clazz, object : PLYActionInterceptorCallback { + override fun onIntercept( + info: PLYInterceptorInfo, + action: PLYPresentationAction, + completion: (PLYInterceptResult) -> Unit + ) { + val callback = actionInterceptorCallbacks[kind] + if (callback == null) { + completion(PLYInterceptResult.NOT_HANDLED) + return + } + // Unique id per intercepted invocation so concurrent intercepts resolve independently. + val invocationId = "$kind#${++interceptorInvocationCounter}" + pendingInterceptCompletions[invocationId] = completion + + val map = hashMapOf( + "action" to kind, + "callbackId" to invocationId, + "info" to mapOf( + // TODO(v6-verify): presentationId derived from info.presentation?.screenId; + // v6 PLYInterceptorInfo exposes {contentId, presentation}, not a raw id. + "contentId" to info.contentId, + "presentationId" to info.presentation?.screenId + ), + "parameters" to (actionPayloadToMap(action) ?: emptyMap()) + ) + val result = PluginResult(PluginResult.Status.OK, JSONObject(map)) + result.keepCallback = true + callback.sendPluginResult(result) } }) } - private fun setPaywallActionInterceptor(callbackContext: CallbackContext) { - Purchasely.setPaywallActionsInterceptor { info, action, parameters, processAction -> - paywallActionHandler = processAction - paywallAction = action - - interceptorActivity = WeakReference(info?.activity) + // v6: stop intercepting a single action kind. + private fun unregisterActionInterceptor(kind: String?) { + if (kind == null) return + val clazz = PLYPresentationAction.fromValue(kind)?.java ?: return + actionInterceptorCallbacks.remove(kind) + runCatching { Purchasely.removeActionInterceptor(clazz) }.onFailure { + Log.w("Purchasely", "removeActionInterceptor($kind) failed: ${it.message}") + } + } - val parametersForCordova = hashMapOf(); - parametersForCordova["title"] = parameters.title - parametersForCordova["url"] = parameters.url?.toString() - parametersForCordova["plan"] = transformPlanToMap(parameters.plan) - parametersForCordova["offer"] = mapOf( - "vendorId" to parameters.offer?.vendorId, - "storeOfferId" to parameters.offer?.storeOfferId + private fun actionPayloadToMap(action: PLYPresentationAction): Map? { + return when (action) { + is PLYPresentationAction.Navigate -> mapOf( + "url" to action.url?.toString(), + "title" to action.title ) - parametersForCordova["subscriptionOffer"] = parameters.subscriptionOffer?.toMap() - parametersForCordova["presentation"] = parameters.presentation - parametersForCordova["placement"] = parameters.placement - parametersForCordova["closeReason"] = parameters.closeReason?.name - parametersForCordova["clientReferenceId"] = parameters?.clientReferenceId - parametersForCordova["queryParameterKey"] = parameters?.queryParameterKey - parametersForCordova["webCheckoutProvider"] = parameters?.webCheckoutProvider?.name - - val result = PluginResult( - PluginResult.Status.OK, - JSONObject( - hashMapOf( - 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", parametersForCordova.filterNot { it.value == null }) + is PLYPresentationAction.Purchase -> mapOf( + "plan" to transformPlanToMap(action.plan), + "subscriptionOffer" to action.subscriptionOffer?.toMap(), + "offer" to action.offer?.let { offer -> + mapOf( + "vendorId" to offer.vendorId, + "storeOfferId" to offer.storeOfferId ) - ) + } ) - result.keepCallback = true - callbackContext.sendPluginResult(result) - } - } - - private fun closePresentation(callbackContext: CallbackContext) { - val openedPaywall = productActivity?.activity?.get() - openedPaywall?.finish() - productActivity = null - } - - private fun onProcessAction(processAction: Boolean) { - val activityHandler = interceptorActivity?.get() ?: productActivity?.activity?.get() ?: cordova.activity - activityHandler?.runOnUiThread { - paywallActionHandler?.invoke(processAction) - - interceptorActivity?.clear() - interceptorActivity = null + is PLYPresentationAction.Close -> mapOf("closeReason" to action.closeReason.value) + is PLYPresentationAction.CloseAll -> mapOf("closeReason" to action.closeReason.value) + is PLYPresentationAction.OpenPresentation -> mapOf("presentationId" to action.presentationId) + is PLYPresentationAction.OpenPlacement -> mapOf("placementId" to action.placementId) + is PLYPresentationAction.WebCheckout -> mapOf( + "url" to action.url?.toString(), + "clientReferenceId" to action.clientReferenceId, + "queryParameterKey" to action.queryParameterKey, + "webCheckoutProvider" to action.webCheckoutProvider?.name + ) + else -> null } } - private fun showPresentation() { - val currentActivity = interceptorActivity?.get() - - if (currentActivity != null && !currentActivity.isFinishing && !currentActivity.isDestroyed) { - cordova.activity?.let { - it.startActivity( - Intent(it, currentActivity::class.java).apply { - //flags = Intent.FLAG_ACTIVITY_NEW_TASK - flags = Intent.FLAG_ACTIVITY_REORDER_TO_FRONT - } - ) - } + // v6: JS reports how an intercepted action was handled, keyed by the invocation id + // carried on the intercept event. Result is "success"/"failed"/"notHandled". + private fun completeActionInterceptor(callbackId: String?, result: String?) { + if (callbackId == null) return + val completion = pendingInterceptCompletions.remove(callbackId) ?: return + val plyResult = when (result) { + "success" -> PLYInterceptResult.SUCCESS + "failed" -> PLYInterceptResult.FAILED + else -> PLYInterceptResult.NOT_HANDLED } - else { - productActivity?.relaunch(cordova) + val activity = cordova.activity + if (activity != null) { + activity.runOnUiThread { completion.invoke(plyResult) } + } else { + completion.invoke(plyResult) } } - - private fun hidePresentation() { - val cordovaActivity = cordova.activity - val activity = productActivity?.activity?.get() ?: cordovaActivity - cordovaActivity?.startActivity( - Intent(activity, cordovaActivity::class.java).apply { - flags = Intent.FLAG_ACTIVITY_REORDER_TO_FRONT - } - ) - } + //endregion fun setUserAttributeWithStringArray(key: String?, value: JSONArray?, legalBasisString: String?) { if(key == null || value == null) return @@ -1033,14 +1068,15 @@ class PurchaselyPlugin : CordovaPlugin() { } private fun isEligibleForIntroOffer(planId: String?, callbackContext: CallbackContext) { - Purchasely.plan(planId, - onSuccess = { plan -> - callbackContext.sendPluginResult(PluginResult(PluginResult.Status.OK, plan?.isEligibleToIntroOffer(null) ?: false)) - }, - onError = { error -> - callbackContext.error(error.message ?: "Unable to fetch plan") + launch { + try { + val plan = Purchasely.plan(planId ?: "") + val eligible = plan?.isEligibleToOffer(null) ?: false + callbackContext.sendPluginResult(PluginResult(PluginResult.Status.OK, eligible)) + } catch (e: Exception) { + callbackContext.error(e.message ?: "Unable to fetch plan") } - ) + } } private fun signPromotionalOffer(storeProductId: String?, storeOfferId: String?, callbackContext: CallbackContext) { @@ -1074,103 +1110,85 @@ class PurchaselyPlugin : CordovaPlugin() { Purchasely.debugMode = enabled } - class ProductActivity { - var presentationId: String? = null - var placementId: String? = null - var productId: String? = null - var planId: String? = null - var contentId: String? = null - var presentation: PLYPresentation? = null - var isFullScreen = false - var loadingBackgroundColor: String? = null - var activity: WeakReference? = null - fun relaunch(cordova: CordovaInterface): Boolean { - var backgroundActivity: PLYProductActivity? = null - if (activity != null) { - backgroundActivity = activity?.get() - } - return if (backgroundActivity != null && !backgroundActivity.isFinishing - && !backgroundActivity.isDestroyed - ) { - val intent = Intent(cordova.activity, PLYProductActivity::class.java) - intent.putExtra("presentation", presentation) - intent.putExtra("presentationId", presentationId) - intent.putExtra("placementId", placementId) - intent.putExtra("productId", productId) - intent.putExtra("planId", planId) - intent.putExtra("contentId", contentId) - intent.putExtra("isFullScreen", isFullScreen) - intent.putExtra("background_color", loadingBackgroundColor) - intent.flags = Intent.FLAG_ACTIVITY_REORDER_TO_FRONT - cordova.activity.startActivity(intent) - true - } else { - val intent = PLYProductActivity.newIntent(cordova.activity) - intent.putExtra("presentation", presentation) - intent.putExtra("presentationId", presentationId) - intent.putExtra("placementId", placementId) - intent.putExtra("productId", productId) - intent.putExtra("planId", planId) - intent.putExtra("contentId", contentId) - intent.putExtra("isFullScreen", isFullScreen) - intent.putExtra("background_color", loadingBackgroundColor) - cordova.activity.startActivity(intent) - false - } - } - } - companion object { var defaultCallback: CallbackContext? = null var purchaseCallback: CallbackContext? = null var eventsCallback: CallbackContext? = null var attributesCallback: CallbackContext? = null - var productActivity: ProductActivity? = null - - var interceptorActivity: WeakReference? = null - val presentationsLoaded = mutableListOf() - - private const val runningModePaywallObserver = 2 - private const val runningModeFull = 3 - - fun sendPurchaseResult(result: PLYProductViewResult, plan: PLYPlan?) { - var productViewResult = 0 - if (result == PLYProductViewResult.PURCHASED) { - productViewResult = PLYProductViewResult.PURCHASED.ordinal - } else if (result == PLYProductViewResult.CANCELLED) { - productViewResult = PLYProductViewResult.CANCELLED.ordinal - } else if (result == PLYProductViewResult.RESTORED) { - productViewResult = PLYProductViewResult.RESTORED.ordinal - } - val map = HashMap() - map["result"] = productViewResult - map["plan"] = transformPlanToMap(plan) - if (purchaseCallback != null) { - purchaseCallback?.success(JSONObject(map)) + // Resolves a present*/presentPresentation call at dismissal. One-shot for the + // purchase callback; falls back to the (repeatable) default dismiss handler. + fun sendPurchaseResult(outcome: PLYPresentationOutcome) { + val json = JSONObject(outcomeToMap(outcome)) + val oneShot = purchaseCallback + if (oneShot != null) { + oneShot.success(json) purchaseCallback = null - } else if (defaultCallback != null) { - val pluginResult = PluginResult(PluginResult.Status.OK, JSONObject(map)) + return + } + defaultCallback?.let { + val pluginResult = PluginResult(PluginResult.Status.OK, json) pluginResult.keepCallback = true - defaultCallback?.sendPluginResult(pluginResult) + it.sendPluginResult(pluginResult) } } + // Serializes a v6 PLYPresentationOutcome to the wire contract. `result` is kept as an + // int (PurchaseResult 0/1/2) for back-compat with the pre-6.0 JS layer. + fun outcomeToMap(outcome: PLYPresentationOutcome): Map { + val result = when (outcome.purchaseResult?.name?.lowercase(Locale.US)) { + "purchased" -> 0 + "cancelled" -> 1 + "restored" -> 2 + else -> 1 // no purchase (plain dismiss) → cancelled + } + val map = HashMap() + map["result"] = result + map["plan"] = transformPlanToMap(outcome.plan) + map["closeReason"] = outcome.closeReason?.value + map["error"] = outcome.error?.message + map["presentation"] = outcome.presentation?.let { presentationToMap(it) } + return map + } + + fun presentationToMap(p: PLYPresentationBase.Loaded): Map { + return mapOf( + "screenId" to p.screenId, + "placementId" to p.placementId, + "contentId" to p.contentId, + "audienceId" to p.audienceId, + "abTestId" to p.abTestId, + "abTestVariantId" to p.abTestVariantId, + "campaignId" to p.campaignId, + "flowId" to p.flowId, + "language" to p.language, + "type" to p.type.ordinal, + "height" to p.height, + "plans" to p.plans.map { presentationPlanToMap(it) } + ) + } + + private fun presentationPlanToMap(plan: PLYPresentationPlan): Map { + return mapOf( + "planVendorId" to plan.planVendorId, + "storeProductId" to plan.storeProductId, + "basePlanId" to plan.basePlanId, + "offerId" to plan.storeOfferId + ) + } + private fun transformPlanToMap(plan: PLYPlan?): Map { if (plan == null) return HashMap() val map = HashMap(plan.toMap()) - if (plan.type == DistributionType.CONSUMABLE) { - map["type"] = DistributionType.CONSUMABLE.ordinal - } else if (plan.type == DistributionType.CONSUMABLE) { - map["type"] = DistributionType.NON_CONSUMABLE.ordinal - } else if (plan.type == DistributionType.NON_CONSUMABLE) { - map["type"] = DistributionType.RENEWING_SUBSCRIPTION.ordinal - } else if (plan.type == DistributionType.NON_RENEWING_SUBSCRIPTION) { - map["type"] = DistributionType.NON_RENEWING_SUBSCRIPTION.ordinal - } else if (plan.type == DistributionType.UNKNOWN) { - map["type"] = DistributionType.UNKNOWN.ordinal + map["type"] = when (plan.type) { + DistributionType.RENEWING_SUBSCRIPTION -> DistributionType.RENEWING_SUBSCRIPTION.ordinal + DistributionType.NON_RENEWING_SUBSCRIPTION -> DistributionType.NON_RENEWING_SUBSCRIPTION.ordinal + DistributionType.CONSUMABLE -> DistributionType.CONSUMABLE.ordinal + DistributionType.NON_CONSUMABLE -> DistributionType.NON_CONSUMABLE.ordinal + DistributionType.UNKNOWN -> DistributionType.UNKNOWN.ordinal + else -> null } - map["isEligibleForIntroOffer"] = plan.isEligibleToIntroOffer(null) + map["isEligibleForIntroOffer"] = plan.isEligibleToOffer(null) return map } } @@ -1223,4 +1241,4 @@ class PurchaselyPlugin : CordovaPlugin() { BATCH_CUSTOM_USER_ID: 20, */ } -} \ No newline at end of file +} diff --git a/purchasely/src/android/activity_ply_product_activity.xml b/purchasely/src/android/activity_ply_product_activity.xml deleted file mode 100644 index 048fecf..0000000 --- a/purchasely/src/android/activity_ply_product_activity.xml +++ /dev/null @@ -1,6 +0,0 @@ - - \ No newline at end of file diff --git a/purchasely/src/android/activity_ply_subscriptions_activity.xml b/purchasely/src/android/activity_ply_subscriptions_activity.xml deleted file mode 100644 index 3dadaf2..0000000 --- a/purchasely/src/android/activity_ply_subscriptions_activity.xml +++ /dev/null @@ -1,6 +0,0 @@ - - \ No newline at end of file diff --git a/purchasely/src/android/theme_purchasely_fullscreen.xml b/purchasely/src/android/theme_purchasely_fullscreen.xml deleted file mode 100755 index e54455e..0000000 --- a/purchasely/src/android/theme_purchasely_fullscreen.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - diff --git a/purchasely/src/android/theme_purchasely_fullscreen_v23.xml b/purchasely/src/android/theme_purchasely_fullscreen_v23.xml deleted file mode 100755 index e54455e..0000000 --- a/purchasely/src/android/theme_purchasely_fullscreen_v23.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - diff --git a/purchasely/src/android/theme_purchasely_fullscreen_v23_light_status_bar.xml b/purchasely/src/android/theme_purchasely_fullscreen_v23_light_status_bar.xml deleted file mode 100755 index 82f8306..0000000 --- a/purchasely/src/android/theme_purchasely_fullscreen_v23_light_status_bar.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - diff --git a/purchasely/src/ios/CDVPurchasely+Events.h b/purchasely/src/ios/CDVPurchasely+Events.h index 6a2a79d..ee384c4 100644 --- a/purchasely/src/ios/CDVPurchasely+Events.h +++ b/purchasely/src/ios/CDVPurchasely+Events.h @@ -8,7 +8,7 @@ #import #import "CDVPurchasely.h" -@interface CDVPurchasely (Events) { +@interface CDVPurchasely (Events) { } diff --git a/purchasely/src/ios/CDVPurchasely+UserAttributes.h b/purchasely/src/ios/CDVPurchasely+UserAttributes.h index ea72e6c..fe34f35 100644 --- a/purchasely/src/ios/CDVPurchasely+UserAttributes.h +++ b/purchasely/src/ios/CDVPurchasely+UserAttributes.h @@ -8,7 +8,7 @@ #import #import "CDVPurchasely.h" -@interface CDVPurchasely (UserAttributes) { +@interface CDVPurchasely (UserAttributes) { } diff --git a/purchasely/src/ios/CDVPurchasely+UserAttributes.m b/purchasely/src/ios/CDVPurchasely+UserAttributes.m index 0401752..6bcb0da 100644 --- a/purchasely/src/ios/CDVPurchasely+UserAttributes.m +++ b/purchasely/src/ios/CDVPurchasely+UserAttributes.m @@ -16,6 +16,7 @@ - (void)onUserAttributeSetWithKey:(NSString * _Nonnull)key type:(enum PLYUserAttributeType)type value:(id _Nullable)value source:(enum PLYUserAttributeSource)source { + if (self.attributeCommand == nil) { return; } NSDictionary *attributeDic = [self dictionaryFromUserAttributeWithKey:key type:type value:value source:source]; CDVPluginResult* pluginResult = [CDVPluginResult resultWithStatus:CDVCommandStatus_OK messageAsDictionary:attributeDic]; @@ -26,6 +27,7 @@ - (void)onUserAttributeSetWithKey:(NSString * _Nonnull)key - (void)onUserAttributeRemovedWithKey:(NSString * _Nonnull)key source:(enum PLYUserAttributeSource)source { + if (self.attributeCommand == nil) { return; } NSString *sourceString = [PLYUserAttributeSourceHelper stringFromUserAttributeSource:source]; NSMutableDictionary *attributeDic = [@{ diff --git a/purchasely/src/ios/CDVPurchasely.h b/purchasely/src/ios/CDVPurchasely.h index 7e96830..0d974bb 100644 --- a/purchasely/src/ios/CDVPurchasely.h +++ b/purchasely/src/ios/CDVPurchasely.h @@ -8,22 +8,29 @@ #import #import -@interface CDVPurchasely : CDVPlugin { +// Protocol conformance (PLYEventDelegate / PLYUserAttributeDelegate) is declared on the +// CDVPurchasely (Events) and (UserAttributes) categories, which implement the delegate methods. +@interface CDVPurchasely : CDVPlugin { } -@property (nonatomic, retain) UIViewController* presentedPresentationViewController; +// The presentation currently displayed (v6 uses id for close()/back()). +@property (nonatomic, strong) id currentPresentation; @property CDVInvokedUrlCommand* purchasedCommand; @property CDVInvokedUrlCommand* eventCommand; @property CDVInvokedUrlCommand* attributeCommand; -@property (nonatomic) NSMutableArray *presentationsLoaded; -@property (nonatomic, assign) Boolean shouldReopenPaywall; +@property (nonatomic) NSMutableArray> *presentationsLoaded; @property (nonatomic) CDVInvokedUrlCommand* purchaseResolve; -@property CDVInvokedUrlCommand* paywallActionInterceptorCommand; -@property void (^onProcessActionHandler)(BOOL proceed); +// v6 per-action interceptor state. Each registered action kind keeps its own +// Cordova callbackId (to emit intercept events); each intercepted invocation +// stashes its PLYInterceptResult completion under a unique id so concurrent +// intercepts resolve independently (was a single stashed completion before). +@property (nonatomic, strong) NSMutableDictionary *actionInterceptorCallbackIds; +@property (nonatomic, strong) NSMutableDictionary *pendingInterceptCompletions; +@property (nonatomic) NSUInteger interceptorInvocationCounter; - (void)start:(CDVInvokedUrlCommand*)command; - (void)setLogLevel:(CDVInvokedUrlCommand*)command; @@ -31,13 +38,12 @@ - (void)userLogout:(CDVInvokedUrlCommand*)command; - (void)setAttribute:(CDVInvokedUrlCommand*)command; - (void)getAnonymousUserId:(CDVInvokedUrlCommand*)command; -- (void)readyToOpenDeeplink:(CDVInvokedUrlCommand*)command; -- (void)setDefaultPresentationResultHandler:(CDVInvokedUrlCommand*)command; +- (void)allowDeeplink:(CDVInvokedUrlCommand*)command; +- (void)allowCampaigns:(CDVInvokedUrlCommand*)command; +- (void)handleDeeplink:(CDVInvokedUrlCommand*)command; +- (void)setDefaultPresentationDismissHandler:(CDVInvokedUrlCommand*)command; - (void)presentPresentationWithIdentifier:(CDVInvokedUrlCommand*)command; - (void)presentPresentationForPlacement:(CDVInvokedUrlCommand*)command; -- (void)presentPlanWithIdentifier:(CDVInvokedUrlCommand*)command; -- (void)presentProductWithIdentifier:(CDVInvokedUrlCommand*)command; -- (void)presentSubscriptions:(CDVInvokedUrlCommand*)command; - (void)purchaseWithPlanVendorId:(CDVInvokedUrlCommand*)command; - (void)restoreAllProducts:(CDVInvokedUrlCommand*)command; - (void)silentRestoreAllProducts:(CDVInvokedUrlCommand*)command; @@ -50,12 +56,11 @@ - (void)userSubscriptionsHistory:(CDVInvokedUrlCommand*)command; - (void)addEventsListener:(CDVInvokedUrlCommand*)command; - (void)removeEventsListener:(CDVInvokedUrlCommand*)command; -- (void)isDeeplinkHandled:(CDVInvokedUrlCommand*)command; -- (void)setPaywallActionInterceptor:(CDVInvokedUrlCommand*)command; -- (void)onProcessAction:(CDVInvokedUrlCommand*)command; +- (void)registerActionInterceptor:(CDVInvokedUrlCommand*)command; +- (void)unregisterActionInterceptor:(CDVInvokedUrlCommand*)command; +- (void)completeActionInterceptor:(CDVInvokedUrlCommand*)command; - (void)closePresentation:(CDVInvokedUrlCommand*)command; -- (void)hidePresentation:(CDVInvokedUrlCommand*)command; -- (void)showPresentation:(CDVInvokedUrlCommand*)command; +- (void)backPresentation:(CDVInvokedUrlCommand*)command; - (void)userDidConsumeSubscriptionContent:(CDVInvokedUrlCommand*)command; - (void)setUserAttributeWithStringArray:(CDVInvokedUrlCommand*)command; - (void)setUserAttributeWithIntArray:(CDVInvokedUrlCommand*)command; diff --git a/purchasely/src/ios/CDVPurchasely.m b/purchasely/src/ios/CDVPurchasely.m index 2ded4d8..1286e1c 100644 --- a/purchasely/src/ios/CDVPurchasely.m +++ b/purchasely/src/ios/CDVPurchasely.m @@ -8,6 +8,7 @@ #import "CDVPurchasely.h" #import "Purchasely_Hybrid.h" #import "CDVPurchasely+Events.h" +#import "CDVPurchasely+UserAttributes.h" #import "UIColor+PLYHelper.h" @implementation CDVPurchasely @@ -16,35 +17,88 @@ - (instancetype)init { self = [super init]; self.presentationsLoaded = [NSMutableArray new]; - self.shouldReopenPaywall = NO; + self.actionInterceptorCallbackIds = [NSMutableDictionary new]; + self.pendingInterceptCompletions = [NSMutableDictionary new]; return self; } - (void)start:(CDVInvokedUrlCommand*)command { - NSString *apiKey = [command argumentAtIndex:0]; - BOOL storeKit1 = [[command argumentAtIndex:2] boolValue]; - NSString *userId = [command argumentAtIndex:3]; - NSInteger logLevel = [[command argumentAtIndex:4] intValue]; - NSInteger runningMode = [[command argumentAtIndex:5] intValue]; - NSString *purchaselySdkVersion = [command argumentAtIndex:6]; + // v6: a single options dictionary (see the JS↔native contract), no longer positional args. + NSDictionary *opts = [command argumentAtIndex:0]; + if (![opts isKindOfClass:[NSDictionary class]]) { + [self failureFor:command resultString:@"start requires an options object"]; + return; + } + + NSString *apiKey = opts[@"apiKey"]; + if (![apiKey isKindOfClass:[NSString class]] || apiKey.length == 0) { + [self failureFor:command resultString:@"apiKey is required"]; + return; + } - [Purchasely setSdkBridgeVersion:purchaselySdkVersion]; + PurchaselyBuilder *builder = [Purchasely apiKey:apiKey]; + builder = [builder appTechnology:PLYAppTechnologyCordova]; - [Purchasely setAppTechnology:PLYAppTechnologyCordova]; + NSString *sdkVersion = opts[@"sdkVersion"]; + if ([sdkVersion isKindOfClass:[NSString class]]) { + builder = [builder sdkBridgeVersion:sdkVersion]; + } - [Purchasely startWithAPIKey:apiKey - appUserId:userId - runningMode:runningMode - paywallActionsInterceptor:nil - storekitSettings: storeKit1 ? [StorekitSettings storeKit1] : [StorekitSettings storeKit2] - logLevel:logLevel - initialized:^(BOOL initialized, NSError * _Nullable error) { + NSString *appUserId = opts[@"appUserId"]; + if ([appUserId isKindOfClass:[NSString class]] && appUserId.length > 0) { + builder = [builder appUserId:appUserId]; + } + // runningMode is a string ("observer"/"full"), mapped by NAME (iOS enum: observer=2, full=3). + NSString *runningMode = opts[@"runningMode"]; + enum PLYRunningMode mode = PLYRunningModeObserver; + if ([runningMode isKindOfClass:[NSString class]] && [runningMode.lowercaseString isEqualToString:@"full"]) { + mode = PLYRunningModeFull; + } + builder = [builder runningMode:mode]; + + NSNumber *logLevel = opts[@"logLevel"]; + if ([logLevel isKindOfClass:[NSNumber class]]) { + builder = [builder logLevel:(enum PLYLogLevel)logLevel.integerValue]; + } + + // StoreKit selection (iOS): storeKit1 bool OR storekitVersion == "storeKit1" forces StoreKit 1. + BOOL storeKit1 = NO; + NSNumber *storeKit1Num = opts[@"storeKit1"]; + if ([storeKit1Num isKindOfClass:[NSNumber class]]) { + storeKit1 = storeKit1Num.boolValue; + } + NSString *storekitVersion = opts[@"storekitVersion"]; + if ([storekitVersion isKindOfClass:[NSString class]] && [storekitVersion isEqualToString:@"storeKit1"]) { + storeKit1 = YES; + } + builder = [builder storekitSettings: storeKit1 ? [StorekitSettings storeKit1] : [StorekitSettings storeKit2]]; + + NSNumber *allowDeeplink = opts[@"allowDeeplink"]; + if ([allowDeeplink isKindOfClass:[NSNumber class]]) { + builder = [builder allowDeeplink:allowDeeplink.boolValue]; + } + + NSNumber *allowCampaigns = opts[@"allowCampaigns"]; + if ([allowCampaigns isKindOfClass:[NSNumber class]]) { + builder = [builder allowCampaigns:allowCampaigns.boolValue]; + } + + // Cold-start deeplink URL captured at launch (handled automatically once start completes). + NSString *deeplink = opts[@"deeplink"]; + if ([deeplink isKindOfClass:[NSString class]] && deeplink.length > 0) { + NSURL *url = [NSURL URLWithString:deeplink]; + if (url != nil) { + builder = [builder handleDeeplink:url]; + } + } + + [builder startWithInitialized:^(NSError * _Nullable error) { if (error != nil) { [self failureFor:command resultString: error.localizedDescription]; } else { - [self successFor:command resultBool:initialized]; + [self successFor:command resultBool:YES]; } }]; } @@ -80,7 +134,8 @@ - (void)setAttribute:(CDVInvokedUrlCommand*)command { } NSInteger rawAttribute = [attributeNumber integerValue]; - PLYAttribute *attribute = nil; + PLYAttribute attribute = PLYAttributeFirebaseAppInstanceId; + BOOL attributeFound = YES; switch (rawAttribute) { case CordovaPLYAttributeFirebaseAppInstanceId: @@ -146,10 +201,13 @@ - (void)setAttribute:(CDVInvokedUrlCommand*)command { case CordovaPLYAttributeBatchCustomUserId: attribute = PLYAttributeBatchCustomUserId; break; + default: + attributeFound = NO; + break; } - if (attribute == nil) { + if (!attributeFound) { return; } @@ -161,14 +219,19 @@ - (void)getAnonymousUserId:(CDVInvokedUrlCommand*)command { [self successFor:command resultString:anonymousId]; } -- (void)readyToOpenDeeplink:(CDVInvokedUrlCommand*)command { - BOOL isReadyToPurchase = [[command argumentAtIndex:0] boolValue]; - [Purchasely readyToOpenDeeplink: isReadyToPurchase]; +- (void)allowDeeplink:(CDVInvokedUrlCommand*)command { + BOOL allow = [[command argumentAtIndex:0] boolValue]; + [Purchasely allowDeeplink: allow]; } -- (void)setDefaultPresentationResultHandler:(CDVInvokedUrlCommand*)command { - [Purchasely setDefaultPresentationResultHandler:^(enum PLYProductViewControllerResult result, PLYPlan * _Nullable plan) { - NSDictionary *resultDict = [self resultDictionaryForPresentationController:result plan:plan]; +- (void)allowCampaigns:(CDVInvokedUrlCommand*)command { + BOOL allow = [[command argumentAtIndex:0] boolValue]; + [Purchasely allowCampaigns: allow]; +} + +- (void)setDefaultPresentationDismissHandler:(CDVInvokedUrlCommand*)command { + [Purchasely setDefaultPresentationDismissHandler:^(PLYPresentationOutcome * _Nonnull outcome) { + NSDictionary *resultDict = [self resultDictionaryForOutcome:outcome]; CDVPluginResult *pluginResult = [CDVPluginResult resultWithStatus:CDVCommandStatus_OK messageAsDictionary:resultDict]; [pluginResult setKeepCallbackAsBool:YES]; @@ -179,115 +242,55 @@ - (void)setDefaultPresentationResultHandler:(CDVInvokedUrlCommand*)command { - (void)presentPresentationWithIdentifier:(CDVInvokedUrlCommand*)command { NSString *presentationVendorId = [command argumentAtIndex:0]; NSString *contentId = [command argumentAtIndex:1]; - BOOL isFullscreen = [[command argumentAtIndex:2] boolValue]; - - UIViewController *ctrl = [Purchasely presentationControllerWith:presentationVendorId contentId:contentId loaded:nil completion:^(enum PLYProductViewControllerResult result, PLYPlan * _Nullable plan) { - NSDictionary *resultDict = [self resultDictionaryForPresentationController:result plan:plan]; - [self successFor:command resultDict:resultDict]; - }]; - - if (ctrl != nil) { - UINavigationController *navCtrl = [[UINavigationController alloc] initWithRootViewController:ctrl]; - [navCtrl.navigationBar setTranslucent:YES]; - [navCtrl.navigationBar setBackgroundImage:[UIImage new] forBarMetrics:UIBarMetricsDefault]; - [navCtrl.navigationBar setShadowImage: [UIImage new]]; - [navCtrl.navigationBar setTintColor: [UIColor whiteColor]]; + NSString *displayMode = [command argumentAtIndex:2]; - self.presentedPresentationViewController = navCtrl; + if (![presentationVendorId isKindOfClass:[NSString class]]) { + [self failureFor:command resultString:@"presentationId is required"]; + return; + } - if (isFullscreen) { - navCtrl.modalPresentationStyle = UIModalPresentationFullScreen; + dispatch_async(dispatch_get_main_queue(), ^{ + PLYPresentationBuilder *builder = [PLYPresentationBuilder forScreenId:presentationVendorId]; + if ([contentId isKindOfClass:[NSString class]]) { + builder = [builder contentId:contentId]; } - [Purchasely showController:navCtrl type: PLYUIControllerTypeProductPage from:nil]; - } + builder = [builder onPresented:^(id _Nullable presentation, NSError * _Nullable error) { + self.currentPresentation = presentation; + }]; + builder = [builder onDismissed:^(PLYPresentationOutcome * _Nonnull outcome) { + [self successFor:command resultDict:[self resultDictionaryForOutcome:outcome]]; + }]; + + id request = [builder build]; + [request displayWithTransition:[self displayModeFromString:displayMode] completion:nil]; + }); } - (void)presentPresentationForPlacement:(CDVInvokedUrlCommand*)command { NSString *placementVendorId = [command argumentAtIndex:0]; NSString *contentId = [command argumentAtIndex:1]; - BOOL isFullscreen = [[command argumentAtIndex:2] boolValue]; + NSString *displayMode = [command argumentAtIndex:2]; - UIViewController *ctrl = [Purchasely presentationControllerFor:placementVendorId contentId:contentId loaded:nil completion:^(enum PLYProductViewControllerResult result, PLYPlan * _Nullable plan) { - NSDictionary *resultDict = [self resultDictionaryForPresentationController:result plan:plan]; - [self successFor:command resultDict:resultDict]; - }]; - - if (ctrl != nil) { - UINavigationController *navCtrl = [[UINavigationController alloc] initWithRootViewController:ctrl]; - [navCtrl.navigationBar setTranslucent:YES]; - [navCtrl.navigationBar setBackgroundImage:[UIImage new] forBarMetrics:UIBarMetricsDefault]; - [navCtrl.navigationBar setShadowImage: [UIImage new]]; - [navCtrl.navigationBar setTintColor: [UIColor whiteColor]]; - - self.presentedPresentationViewController = navCtrl; - - if (isFullscreen) { - navCtrl.modalPresentationStyle = UIModalPresentationFullScreen; - } - [Purchasely showController:navCtrl type: PLYUIControllerTypeProductPage from:nil]; - } -} - -- (void)presentPlanWithIdentifier:(CDVInvokedUrlCommand*)command { - NSString *planVendorId = [command argumentAtIndex:0]; - NSString *presentationVendorId = [command argumentAtIndex:1]; - NSString *contentId = [command argumentAtIndex:2]; - BOOL isFullscreen = [[command argumentAtIndex:3] boolValue]; - - UIViewController *ctrl = [Purchasely planControllerFor:planVendorId with:presentationVendorId contentId:contentId loaded:nil completion:^(enum PLYProductViewControllerResult result, PLYPlan * _Nullable plan) { - NSDictionary *resultDict = [self resultDictionaryForPresentationController:result plan:plan]; - [self successFor:command resultDict:resultDict]; - }]; - - if (ctrl != nil) { - UINavigationController *navCtrl = [[UINavigationController alloc] initWithRootViewController:ctrl]; - [navCtrl.navigationBar setTranslucent:YES]; - [navCtrl.navigationBar setBackgroundImage:[UIImage new] forBarMetrics:UIBarMetricsDefault]; - [navCtrl.navigationBar setShadowImage: [UIImage new]]; - [navCtrl.navigationBar setTintColor: [UIColor whiteColor]]; - - self.presentedPresentationViewController = navCtrl; - - if (isFullscreen) { - navCtrl.modalPresentationStyle = UIModalPresentationFullScreen; - } - [Purchasely showController:navCtrl type: PLYUIControllerTypeProductPage from:nil]; + if (![placementVendorId isKindOfClass:[NSString class]]) { + [self failureFor:command resultString:@"placementId is required"]; + return; } -} - -- (void)presentProductWithIdentifier:(CDVInvokedUrlCommand*)command { - NSString *productVendorId = [command argumentAtIndex:0]; - NSString *presentationVendorId = [command argumentAtIndex:1]; - NSString *contentId = [command argumentAtIndex:2]; - BOOL isFullscreen = [[command argumentAtIndex:3] boolValue]; - - UIViewController *ctrl = [Purchasely productControllerFor:productVendorId with:presentationVendorId contentId:contentId loaded:nil completion:^(enum PLYProductViewControllerResult result, PLYPlan * _Nullable plan) { - NSDictionary *resultDict = [self resultDictionaryForPresentationController:result plan:plan]; - [self successFor:command resultDict:resultDict]; - }]; - - if (ctrl != nil) { - UINavigationController *navCtrl = [[UINavigationController alloc] initWithRootViewController:ctrl]; - [navCtrl.navigationBar setTranslucent:YES]; - [navCtrl.navigationBar setBackgroundImage:[UIImage new] forBarMetrics:UIBarMetricsDefault]; - [navCtrl.navigationBar setShadowImage: [UIImage new]]; - [navCtrl.navigationBar setTintColor: [UIColor whiteColor]]; - self.presentedPresentationViewController = navCtrl; - - if (isFullscreen) { - navCtrl.modalPresentationStyle = UIModalPresentationFullScreen; + dispatch_async(dispatch_get_main_queue(), ^{ + PLYPresentationBuilder *builder = [PLYPresentationBuilder forPlacementId:placementVendorId]; + if ([contentId isKindOfClass:[NSString class]]) { + builder = [builder contentId:contentId]; } - [Purchasely showController:navCtrl type: PLYUIControllerTypeProductPage from:nil]; - } -} - -- (void)presentSubscriptions:(CDVInvokedUrlCommand*)command { - UIViewController *ctrl = [Purchasely subscriptionsController]; - UINavigationController *navCtrl = [[UINavigationController alloc] initWithRootViewController:ctrl]; - ctrl.navigationItem.leftBarButtonItem = [[UIBarButtonItem alloc] initWithBarButtonSystemItem: UIBarButtonSystemItemDone target:navCtrl action:@selector(close)]; + builder = [builder onPresented:^(id _Nullable presentation, NSError * _Nullable error) { + self.currentPresentation = presentation; + }]; + builder = [builder onDismissed:^(PLYPresentationOutcome * _Nonnull outcome) { + [self successFor:command resultDict:[self resultDictionaryForOutcome:outcome]]; + }]; - [Purchasely showController:navCtrl type: PLYUIControllerTypeSubscriptionList from:nil]; + id request = [builder build]; + [request displayWithTransition:[self displayModeFromString:displayMode] completion:nil]; + }); } - (void)purchaseWithPlanVendorId:(CDVInvokedUrlCommand*)command { @@ -358,7 +361,9 @@ - (void)silentRestoreAllProducts:(CDVInvokedUrlCommand*)command { - (void)synchronize:(CDVInvokedUrlCommand*)command { [Purchasely synchronizeWithSuccess:^{ + [self successFor:command resultBool:YES]; } failure:^(NSError * _Nonnull error) { + [self failureFor:command resultString:error.localizedDescription]; }]; } @@ -444,12 +449,14 @@ - (void)addEventsListener:(CDVInvokedUrlCommand*)command { } - (void)removeEventsListener:(CDVInvokedUrlCommand*)command { - [Purchasely setEventDelegate:nil]; + // v6 `setEventDelegate:` is _Nonnull (no native unregister). The delegate stays + // registered; clearing eventCommand makes `eventTriggered:` a no-op. self.eventCommand = nil; } - (void)removeUserAttributeListener:(CDVInvokedUrlCommand*)command { - [Purchasely setUserAttributeDelegate:nil]; + // v6 `setUserAttributeDelegate:` is _Nonnull (no native unregister). Clearing + // attributeCommand makes the user-attribute callbacks a no-op. self.attributeCommand = nil; } @@ -458,12 +465,12 @@ - (void)addUserAttributeListener:(CDVInvokedUrlCommand*)command { self.attributeCommand = command; } -- (void)isDeeplinkHandled:(CDVInvokedUrlCommand*)command { +- (void)handleDeeplink:(CDVInvokedUrlCommand*)command { NSString *deeplinkString = [command argumentAtIndex:0]; NSURL *deeplink = [NSURL URLWithString:deeplinkString]; if (deeplink != nil) { - BOOL result = [Purchasely isDeeplinkHandledWithDeeplink:deeplink]; + BOOL result = [Purchasely handleDeeplink:deeplink]; [self successFor:command resultBool:result]; } else { [self successFor:command resultBool:NO]; @@ -509,33 +516,99 @@ - (void)isEligibleForIntroOffer:(CDVInvokedUrlCommand*)command { // Helpers -- (NSDictionary *) resultDictionaryForPresentationController:(PLYProductViewControllerResult)result plan:(PLYPlan * _Nullable)plan { - NSMutableDictionary *productViewResult = [NSMutableDictionary new]; - int resultString; +// v6: builds a PLYDisplayMode from the JS display-mode string (see TransitionType). +// Returns nil for an unknown/absent value so the backend-defined default is honored. +- (PLYDisplayMode * _Nullable) displayModeFromString:(NSString * _Nullable) displayMode { + if (![displayMode isKindOfClass:[NSString class]]) { + return nil; + } + if ([displayMode isEqualToString:@"fullScreen"]) { + return [PLYDisplayMode fullScreen]; + } else if ([displayMode isEqualToString:@"modal"]) { + return [PLYDisplayMode modal]; + } else if ([displayMode isEqualToString:@"push"]) { + return [PLYDisplayMode push]; + } else if ([displayMode isEqualToString:@"inlinePaywall"]) { + return [PLYDisplayMode inlinePaywall]; + } else if ([displayMode isEqualToString:@"drawer"]) { + // TODO(v6-verify): the Cordova contract passes only a display-mode string with no + // dimension info, so drawer defaults to hug height (nil) + dismissible. Wire up + // PLYDimension width/height if the JS contract starts sending them. + return [[PLYDisplayMode alloc] initWithType:PLYDisplayModeTypeDrawer heightPercentage:nil backgroundColors:nil dismissible:YES]; + } else if ([displayMode isEqualToString:@"popin"]) { + // TODO(v6-verify): same as drawer — hug size + dismissible default (no dimension in the string contract). + return [[PLYDisplayMode alloc] initWithType:PLYDisplayModeTypePopin heightPercentage:nil backgroundColors:nil dismissible:YES]; + } + return nil; +} + +// v6: the dismiss outcome now arrives as a PLYPresentationOutcome (replaces the +// (PLYProductViewControllerResult, PLYPlan) pair). Serialized per the JS↔native contract. +- (NSDictionary *) resultDictionaryForOutcome:(PLYPresentationOutcome * _Nonnull)outcome { + NSMutableDictionary *dict = [NSMutableDictionary new]; - switch (result) { - case PLYProductViewControllerResultPurchased: - resultString = PLYProductViewControllerResultPurchased; + // Map the v6 PLYPurchaseResult (cancelled=0, purchased=1, restored=2, none=3) to the + // JS PurchaseResult enum (PURCHASED=0, CANCELLED=1, RESTORED=2) for back-compat. + int result; + switch (outcome.purchaseResult) { + case PLYPurchaseResultPurchased: + result = 0; + break; + case PLYPurchaseResultCancelled: + result = 1; break; - case PLYProductViewControllerResultRestored: - resultString = PLYProductViewControllerResultRestored; + case PLYPurchaseResultRestored: + result = 2; break; - case PLYProductViewControllerResultCancelled: - resultString = PLYProductViewControllerResultCancelled; + case PLYPurchaseResultNone: + // TODO(v6-verify): PLYPurchaseResultNone (no purchase action) has no JS PurchaseResult + // equivalent; mapped to CANCELLED(1). closeReason still conveys the precise reason. + result = 1; break; } + [dict setObject:[NSNumber numberWithInt:result] forKey:@"result"]; - [productViewResult setObject:[NSNumber numberWithInt:resultString] forKey:@"result"]; + if (outcome.plan != nil) { + [dict setObject:[outcome.plan asDictionary] forKey:@"plan"]; + } - if (plan != nil) { - [productViewResult setObject:[plan asDictionary] forKey:@"plan"]; + // closeReason strings match the JS Purchasely.CloseReason enum, aligned with the + // native PLYCloseReason wire contract shared with Flutter (button / back_system / + // programmatic). iOS's interactive (swipe) dismiss maps onto `back_system`; a close + // with no dismiss reason (PLYCloseReasonNone, e.g. after a purchase) omits the key, + // mirroring the null closeReason the Flutter bridge reports on iOS. + NSString *closeReason = nil; + switch (outcome.closeReason) { + case PLYCloseReasonButton: + closeReason = @"button"; + break; + case PLYCloseReasonInteractiveDismiss: + closeReason = @"back_system"; + break; + case PLYCloseReasonProgrammatic: + closeReason = @"programmatic"; + break; + case PLYCloseReasonNone: + break; } - return productViewResult; + if (closeReason != nil) { + [dict setObject:closeReason forKey:@"closeReason"]; + } + + if (outcome.error != nil) { + [dict setObject:outcome.error.localizedDescription forKey:@"error"]; + } + + if (outcome.presentation != nil) { + [dict setObject:[self resultDictionaryForFetchPresentation:outcome.presentation] forKey:@"presentation"]; + } + + return dict; } - (NSDictionary *) resultDictionaryForActionInterceptor:(PLYPresentationAction) action parameters: (PLYPresentationActionParameters * _Nullable) params - presentationInfos: (PLYPresentationInfo * _Nullable) infos { + info: (PLYInterceptorInfo * _Nullable) info { NSMutableDictionary *actionInterceptorResult = [NSMutableDictionary new]; NSString* actionString; @@ -575,22 +648,27 @@ - (void)isEligibleForIntroOffer:(CDVInvokedUrlCommand*)command { [actionInterceptorResult setObject:actionString forKey:@"action"]; - if (infos != nil) { + // v6: PLYPresentationInfo was replaced by PLYInterceptorInfo. The flat id fields + // (presentationId/placementId/abTestId/…) are now accessed through info.presentation. + if (info != nil) { NSMutableDictionary *infosResult = [NSMutableDictionary new]; - if (infos.contentId != nil) { - [infosResult setObject:infos.contentId forKey:@"contentId"]; + if (info.contentId != nil) { + [infosResult setObject:info.contentId forKey:@"contentId"]; } - if (infos.presentationId != nil) { - [infosResult setObject:infos.presentationId forKey:@"presentationId"]; - } - if (infos.placementId != nil) { - [infosResult setObject:infos.placementId forKey:@"placementId"]; - } - if (infos.abTestId != nil) { - [infosResult setObject:infos.abTestId forKey:@"abTestId"]; - } - if (infos.abTestVariantId != nil) { - [infosResult setObject:infos.abTestVariantId forKey:@"abTestVariantId"]; + id presentation = info.presentation; + if (presentation != nil) { + if (presentation.id != nil) { + [infosResult setObject:presentation.id forKey:@"presentationId"]; + } + if (presentation.placementId != nil) { + [infosResult setObject:presentation.placementId forKey:@"placementId"]; + } + if (presentation.abTestId != nil) { + [infosResult setObject:presentation.abTestId forKey:@"abTestId"]; + } + if (presentation.abTestVariantId != nil) { + [infosResult setObject:presentation.abTestVariantId forKey:@"abTestVariantId"]; + } } [actionInterceptorResult setObject:infosResult forKey:@"info"]; @@ -609,6 +687,9 @@ - (void)isEligibleForIntroOffer:(CDVInvokedUrlCommand*)command { if (params.presentation != nil) { [paramsResult setObject:params.presentation forKey:@"presentation"]; } + if (params.placement != nil) { + [paramsResult setObject:params.placement forKey:@"placementId"]; + } if (params.promoOffer != nil) { NSMutableDictionary *promoOffer = [NSMutableDictionary new]; [promoOffer setObject:params.promoOffer.vendorId forKey:@"vendorId"]; @@ -635,58 +716,112 @@ - (void)isEligibleForIntroOffer:(CDVInvokedUrlCommand*)command { return @"stripe"; case PLYWebCheckoutProviderOther: return @"other"; + case PLYWebCheckoutProviderNone: + return @"none"; default: return @"unknown"; } } -- (void)setPaywallActionInterceptor:(CDVInvokedUrlCommand*)command { - self.paywallActionInterceptorCommand = command; - [Purchasely setPaywallActionsInterceptor:^(enum PLYPresentationAction action, PLYPresentationActionParameters * _Nullable parameters, PLYPresentationInfo * _Nullable infos, void (^ _Nonnull onProcessActionHandler)(BOOL)) { - self.onProcessActionHandler = onProcessActionHandler; - NSDictionary *resultDict = [self resultDictionaryForActionInterceptor:action parameters:parameters presentationInfos:infos]; +// Maps a JS action-kind string (see Purchasely.PresentationAction) to a PLYPresentationAction. +// Returns NO for an unknown kind. +static BOOL PLYPresentationActionFromString(NSString *kind, PLYPresentationAction *out) { + static NSDictionary *map; + static dispatch_once_t once; + dispatch_once(&once, ^{ + map = @{ + @"login": @(PLYPresentationActionLogin), + @"purchase": @(PLYPresentationActionPurchase), + @"close": @(PLYPresentationActionClose), + @"close_all": @(PLYPresentationActionCloseAll), + @"restore": @(PLYPresentationActionRestore), + @"navigate": @(PLYPresentationActionNavigate), + @"promo_code": @(PLYPresentationActionPromoCode), + @"open_presentation": @(PLYPresentationActionOpenPresentation), + @"open_placement": @(PLYPresentationActionOpenPlacement), + @"web_checkout": @(PLYPresentationActionWebCheckout), + }; + }); + NSNumber *value = [kind isKindOfClass:[NSString class]] ? map[kind] : nil; + if (value == nil) return NO; + if (out != NULL) *out = (PLYPresentationAction)value.integerValue; + return YES; +} + +// v6: register a JS handler for a single action kind. Each intercept emits an +// event carrying a unique `callbackId`; JS replies via completeActionInterceptor. +- (void)registerActionInterceptor:(CDVInvokedUrlCommand*)command { + NSString *kind = [command argumentAtIndex:0]; + PLYPresentationAction action; + if (!PLYPresentationActionFromString(kind, &action)) { + NSLog(@"[Purchasely] unknown interceptor kind: %@", kind); + return; + } + self.actionInterceptorCallbackIds[kind] = command.callbackId; - CDVPluginResult *pluginResult = [CDVPluginResult resultWithStatus:CDVCommandStatus_OK messageAsDictionary:resultDict]; - [pluginResult setKeepCallbackAsBool:YES]; - [self.commandDelegate sendPluginResult:pluginResult callbackId:self.paywallActionInterceptorCommand.callbackId]; + __weak CDVPurchasely *weakSelf = self; + [Purchasely interceptAction:action handler:^(PLYInterceptorInfo * _Nonnull info, PLYPresentationActionParameters * _Nullable params, void (^ _Nonnull completion)(enum PLYInterceptResult)) { + CDVPurchasely *strongSelf = weakSelf; + if (strongSelf == nil) { completion(PLYInterceptResultNotHandled); return; } + NSString *callbackId = strongSelf.actionInterceptorCallbackIds[kind]; + if (callbackId == nil) { completion(PLYInterceptResultNotHandled); return; } + + // Unique id per intercepted invocation so concurrent intercepts resolve independently. + NSString *invocationId = [NSString stringWithFormat:@"%@#%lu", kind, (unsigned long)(++strongSelf.interceptorInvocationCounter)]; + // Copy the escaping completion onto the heap before stashing (the dictionary retains but + // does not copy; the old single-slot property was declared `copy`). + strongSelf.pendingInterceptCompletions[invocationId] = [completion copy]; + + NSMutableDictionary *event = [[strongSelf resultDictionaryForActionInterceptor:action parameters:params info:info] mutableCopy]; + event[@"callbackId"] = invocationId; + + CDVPluginResult *pluginResult = [CDVPluginResult resultWithStatus:CDVCommandStatus_OK messageAsDictionary:event]; + [pluginResult setKeepCallbackAsBool:YES]; + [strongSelf.commandDelegate sendPluginResult:pluginResult callbackId:callbackId]; }]; } -- (void)onProcessAction:(CDVInvokedUrlCommand*)command { - BOOL processAction = [[command argumentAtIndex:0] boolValue]; - if (self.onProcessActionHandler != nil) { - self.onProcessActionHandler(processAction); - } +// v6: stop intercepting a single action kind. +- (void)unregisterActionInterceptor:(CDVInvokedUrlCommand*)command { + NSString *kind = [command argumentAtIndex:0]; + PLYPresentationAction action; + if (!PLYPresentationActionFromString(kind, &action)) return; + [self.actionInterceptorCallbackIds removeObjectForKey:kind]; + [Purchasely removeActionInterceptor:action]; } -- (void)closePresentation:(CDVInvokedUrlCommand*)command { - if (self.presentedPresentationViewController != nil) { - dispatch_async(dispatch_get_main_queue(), ^{ - [self.presentedPresentationViewController dismissViewControllerAnimated:true completion:^{ - self.presentedPresentationViewController = nil; - self.shouldReopenPaywall = NO; - }]; - }); +// v6: JS reports how an intercepted action was handled, keyed by the invocation +// id carried on the intercept event. Result is "success"/"failed"/"notHandled". +- (void)completeActionInterceptor:(CDVInvokedUrlCommand*)command { + NSString *invocationId = [command argumentAtIndex:0]; + NSString *resultString = [command argumentAtIndex:1]; + if (![invocationId isKindOfClass:[NSString class]]) return; + + void (^completion)(enum PLYInterceptResult) = self.pendingInterceptCompletions[invocationId]; + if (completion == nil) return; + [self.pendingInterceptCompletions removeObjectForKey:invocationId]; + + enum PLYInterceptResult result = PLYInterceptResultNotHandled; + if ([resultString isEqualToString:@"success"]) { + result = PLYInterceptResultSuccess; + } else if ([resultString isEqualToString:@"failed"]) { + result = PLYInterceptResultFailed; } + completion(result); } -- (void)hidePresentation:(CDVInvokedUrlCommand*)command { - if (self.presentedPresentationViewController != nil) { - dispatch_async(dispatch_get_main_queue(), ^{ - [self.presentedPresentationViewController dismissViewControllerAnimated:true completion:^{ }]; - self.shouldReopenPaywall = YES; - }); - } +- (void)closePresentation:(CDVInvokedUrlCommand*)command { + dispatch_async(dispatch_get_main_queue(), ^{ + [Purchasely closeAllScreens]; + self.currentPresentation = nil; + }); } -- (void)showPresentation:(CDVInvokedUrlCommand*)command { +- (void)backPresentation:(CDVInvokedUrlCommand*)command { dispatch_async(dispatch_get_main_queue(), ^{ - if (self.presentedPresentationViewController && self.shouldReopenPaywall) { - dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 0.5 * NSEC_PER_SEC), dispatch_get_main_queue(), ^{ - self.shouldReopenPaywall = NO; - [Purchasely showController:self.presentedPresentationViewController type:PLYUIControllerTypeProductPage from:nil]; - }); + if (self.currentPresentation != nil) { + [self.currentPresentation back]; } }); } @@ -887,84 +1022,75 @@ - (void)fetchPresentation:(CDVInvokedUrlCommand*)command { NSString *contentId = [command argumentAtIndex:2]; dispatch_async(dispatch_get_main_queue(), ^{ - if (placementId != nil) { - [Purchasely fetchPresentationFor:placementId contentId: contentId fetchCompletion:^(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]]; - } - } completion:^(enum PLYProductViewControllerResult result, PLYPlan * _Nullable plan) { - if (self.purchaseResolve != nil){ - [self successFor:self.purchaseResolve resultDict:[self resultDictionaryForPresentationController:result plan:plan]]; - } - } loadedCompletion:nil]; - } else { - [Purchasely fetchPresentationWith:presentationId contentId: contentId fetchCompletion:^(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]]; - } - } completion:^(enum PLYProductViewControllerResult result, PLYPlan * _Nullable plan) { - if (self.purchaseResolve != nil) { - [self successFor:self.purchaseResolve resultDict:[self resultDictionaryForPresentationController:result plan:plan]]; - } - } loadedCompletion:nil]; - } - }); + PLYPresentationBuilder *builder; + if ([placementId isKindOfClass:[NSString class]]) { + builder = [PLYPresentationBuilder forPlacementId:placementId]; + } else if ([presentationId isKindOfClass:[NSString class]]) { + builder = [PLYPresentationBuilder forScreenId:presentationId]; + } else { + [self failureFor:command resultString:@"placementId or presentationId is required"]; + return; + } + + if ([contentId isKindOfClass:[NSString class]]) { + builder = [builder contentId:contentId]; + } + + // v6: build a request and preload it (fetch without display). The purchase/dismiss + // outcome now flows through the later presentPresentation: display, not fetch. + id request = [builder build]; + [request preloadWithCompletion:^(id _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]]; + } + }]; + }); } - (void)presentPresentation:(CDVInvokedUrlCommand *)command { NSDictionary *presentationDictionary = [command argumentAtIndex:0]; - BOOL isFullscreen = [[command argumentAtIndex:1] boolValue]; + NSString *displayMode = [command argumentAtIndex:1]; NSString *loadingBackgroundColor = [command argumentAtIndex:2]; if (presentationDictionary == nil) { - [self failureFor:command resultString: @"Presentation cannot be null"]; - return; - } - - self.purchaseResolve = command; + [self failureFor:command resultString: @"Presentation cannot be null"]; + return; + } - dispatch_async(dispatch_get_main_queue(), ^{ + self.purchaseResolve = command; - PLYPresentation *presentationLoaded = [self findPresentationLoadedFor:(NSString *)[presentationDictionary objectForKey:@"id"]]; + dispatch_async(dispatch_get_main_queue(), ^{ + NSString *presentationId = (NSString *)[presentationDictionary objectForKey:@"id"]; + id presentationLoaded = [self findPresentationLoadedFor:presentationId]; - if (presentationLoaded == nil || presentationLoaded.controller == nil) { - [self failureFor:command resultString: @"Presentation not loaded"]; - return; - } + if (presentationLoaded == nil || presentationLoaded.controller == nil) { + [self failureFor:command resultString: @"Presentation not loaded"]; + return; + } - [self.presentationsLoaded removeObjectAtIndex:[self findIndexPresentationLoadedFor:(NSString *)[presentationDictionary objectForKey:@"id"]]]; + NSInteger index = [self findIndexPresentationLoadedFor:presentationId]; + if (index >= 0) { + [self.presentationsLoaded removeObjectAtIndex:index]; + } - if (presentationLoaded.controller != nil) { - if (loadingBackgroundColor != nil) { - UIColor *backColor = [UIColor ply_fromHex:loadingBackgroundColor]; - if (backColor != nil) { - [presentationLoaded.controller.view setBackgroundColor:backColor]; - } - } + if (loadingBackgroundColor != nil) { + UIColor *backColor = [UIColor ply_fromHex:loadingBackgroundColor]; + if (backColor != nil) { + [presentationLoaded.controller.view setBackgroundColor:backColor]; + } + } - if (isFullscreen) { - presentationLoaded.controller.modalPresentationStyle = UIModalPresentationFullScreen; - } + // Deliver the dismiss outcome to this command through onDismissed. + presentationLoaded.onDismissed = ^(PLYPresentationOutcome * _Nonnull outcome) { + [self successFor:command resultDict:[self resultDictionaryForOutcome:outcome]]; + }; - self.shouldReopenPaywall = NO; - - [Purchasely closeDisplayedPresentation]; - self.presentedPresentationViewController = presentationLoaded.controller; - dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 0.5 * NSEC_PER_SEC), dispatch_get_main_queue(), ^{ - if (presentationLoaded.isFlow) { - [presentationLoaded displayFrom:nil]; - } else { - [Purchasely showController:presentationLoaded.controller type: PLYUIControllerTypeProductPage from:nil]; - } - }); - } - }); + self.currentPresentation = presentationLoaded; + [presentationLoaded displayFrom:nil transitionType:[self displayModeFromString:displayMode]]; + }); } - (void) revokeDataProcessingConsent:(CDVInvokedUrlCommand *)command { @@ -996,8 +1122,8 @@ - (void)setDebugMode:(CDVInvokedUrlCommand*)command { [Purchasely setDebugModeWithEnabled: enabled]; } -- (PLYPresentation *) findPresentationLoadedFor:(NSString * _Nullable) presentationId { - for (PLYPresentation *presentationLoaded in self.presentationsLoaded) { +- (id) findPresentationLoadedFor:(NSString * _Nullable) presentationId { + for (id presentationLoaded in self.presentationsLoaded) { if ([presentationLoaded.id isEqualToString: presentationId]) { return presentationLoaded; } @@ -1007,7 +1133,7 @@ - (PLYPresentation *) findPresentationLoadedFor:(NSString * _Nullable) presentat - (NSInteger) findIndexPresentationLoadedFor:(NSString * _Nullable) presentationId { NSInteger index = 0; - for (PLYPresentation *presentationLoaded in self.presentationsLoaded) { + for (id presentationLoaded in self.presentationsLoaded) { if ([presentationLoaded.id isEqualToString: presentationId]) { return index; } @@ -1038,10 +1164,9 @@ - (NSDictionary *)resultSignatureForSignPromoOffer:(PLYOfferSignature * _Nullabl return dict; } -- (NSDictionary *) resultDictionaryForFetchPresentation:(PLYPresentation * _Nullable) presentation { +- (NSDictionary *) resultDictionaryForFetchPresentation:(id _Nullable) presentation { NSMutableDictionary *presentationResult = [NSMutableDictionary new]; - // TODO: fill all parameters. if (presentation != nil) { if (presentation.id != nil) { @@ -1064,6 +1189,17 @@ - (NSDictionary *)resultSignatureForSignPromoOffer:(PLYOfferSignature * _Nullabl [presentationResult setObject:presentation.abTestVariantId forKey:@"abTestVariantId"]; } + // New in v6: campaignId, flowId, height. + if (presentation.campaignId != nil) { + [presentationResult setObject:presentation.campaignId forKey:@"campaignId"]; + } + + if (presentation.flowId != nil) { + [presentationResult setObject:presentation.flowId forKey:@"flowId"]; + } + + [presentationResult setObject:[NSNumber numberWithInteger:presentation.height] forKey:@"height"]; + if (presentation.language != nil) { [presentationResult setObject:presentation.language forKey:@"language"]; } diff --git a/purchasely/www/Purchasely.js b/purchasely/www/Purchasely.js index 94dc12e..ab6d0c3 100644 --- a/purchasely/www/Purchasely.js +++ b/purchasely/www/Purchasely.js @@ -2,12 +2,39 @@ var exec = require('cordova/exec'); var defaultError = (e) => { console.log(e); } -exports.start = function (apiKey, stores, storekit1, userId, logLevel, runningMode, success, error) { +// Normalize a presentation display mode. Historically the presentation methods +// took an `isFullscreen` boolean; Purchasely 6.0 replaced it with a display mode +// (see Purchasely.TransitionType). Booleans are still accepted for source +// compatibility: true -> fullScreen, false -> modal. +function normalizeDisplayMode(mode) { + if (mode === true) return 'fullScreen'; + if (mode === false) return 'modal'; + return mode || 'fullScreen'; +} + +// Purchasely 6.0: `start` now takes a single options object instead of a +// positional argument list. Existing positional calls are no longer supported +// (see MIGRATION-v6.md). +// +// options: +// apiKey (string, required) +// appUserId (string, optional) +// logLevel (int, optional — see Purchasely.LogLevel) +// runningMode (string, optional — see Purchasely.RunningMode; defaults to observer) +// stores (string[], optional — see Purchasely.Store) +// storeKit1 (bool, optional — iOS; true forces StoreKit 1) +// storekitVersion (string, optional — iOS; see Purchasely.StorekitVersion) +// allowDeeplink (bool, optional) +// allowCampaigns (bool, optional) +// deeplink (string, optional — cold-start deeplink URL) +exports.start = function (options, success, error) { + var opts = options || {}; var cordovaSdkVersion = cordova.define.moduleMap['cordova/plugin_list'].exports['metadata']['cordova-plugin-purchasely'] if(!cordovaSdkVersion) { - cordovaSdkVersion = "5.7.3"; + cordovaSdkVersion = "6.0.0-rc.1"; } - exec(success, error, 'Purchasely', 'start', [apiKey, stores, storekit1, userId, logLevel, runningMode, cordovaSdkVersion]); + opts.sdkVersion = cordovaSdkVersion; + exec(success, error, 'Purchasely', 'start', [opts]); }; exports.addEventsListener = function (success, error) { @@ -46,32 +73,32 @@ exports.setAttribute = function (attribute, value) { exec(() => {}, defaultError, 'Purchasely', 'setAttribute', [attribute, value]); }; -exports.readyToOpenDeeplink = function (isReady) { - exec(() => {}, defaultError, 'Purchasely', 'readyToOpenDeeplink', [isReady]); -}; - -exports.setDefaultPresentationResultHandler = function (success, error) { - exec(success, error, 'Purchasely', 'setDefaultPresentationResultHandler', []); +// Purchasely 6.0: allow (or defer) the SDK from opening deeplinks. +exports.allowDeeplink = function (allow) { + exec(() => {}, defaultError, 'Purchasely', 'allowDeeplink', [allow]); }; -exports.synchronize = function () { - exec(() => {}, defaultError, 'Purchasely', 'synchronize', []); +// Purchasely 6.0: allow (or defer) the SDK from displaying campaign deeplinks. +exports.allowCampaigns = function (allow) { + exec(() => {}, defaultError, 'Purchasely', 'allowCampaigns', [allow]); }; -exports.presentPresentationWithIdentifier = function (presentationId, contentId, isFullscreen, success, error) { - exec(success, error, 'Purchasely', 'presentPresentationWithIdentifier', [presentationId, contentId, isFullscreen]); +exports.setDefaultPresentationDismissHandler = function (success, error) { + exec(success, error, 'Purchasely', 'setDefaultPresentationDismissHandler', []); }; -exports.presentPresentationForPlacement = function (placementId, contentId, isFullscreen, success, error) { - exec(success, error, 'Purchasely', 'presentPresentationForPlacement', [placementId, contentId, isFullscreen]); +// Purchasely 6.0: synchronize now reports completion. success receives true on +// success; error is invoked on failure (previously fire-and-forget). +exports.synchronize = function (success, error) { + exec(success || (() => {}), error || defaultError, 'Purchasely', 'synchronize', []); }; -exports.presentProductWithIdentifier = function (productId, presentationId, contentId, isFullscreen, success, error) { - exec(success, error, 'Purchasely', 'presentProductWithIdentifier', [productId, presentationId, contentId, isFullscreen]); +exports.presentPresentationWithIdentifier = function (presentationId, contentId, displayMode, success, error) { + exec(success, error, 'Purchasely', 'presentPresentationWithIdentifier', [presentationId, contentId, normalizeDisplayMode(displayMode)]); }; -exports.presentPlanWithIdentifier = function (planId, presentationId, contentId, isFullscreen, success, error) { - exec(success, error, 'Purchasely', 'presentPlanWithIdentifier', [planId, presentationId, contentId, isFullscreen]); +exports.presentPresentationForPlacement = function (placementId, contentId, displayMode, success, error) { + exec(success, error, 'Purchasely', 'presentPresentationForPlacement', [placementId, contentId, normalizeDisplayMode(displayMode)]); }; exports.fetchPresentation = function (presentationId, contentId, success, error) { @@ -82,12 +109,8 @@ exports.fetchPresentationForPlacement = function (placementId, contentId, succes exec(success, error, 'Purchasely', 'fetchPresentation', [placementId, null, contentId]); }; -exports.presentPresentation = function (presentation, isFullscreen, backgroundColor,success, error) { - exec(success, error, 'Purchasely', 'presentPresentation', [presentation, isFullscreen, backgroundColor]); -}; - -exports.presentSubscriptions = function () { - exec(() => {}, defaultError, 'Purchasely', 'presentSubscriptions', []); +exports.presentPresentation = function (presentation, displayMode, backgroundColor, success, error) { + exec(success, error, 'Purchasely', 'presentPresentation', [presentation, normalizeDisplayMode(displayMode), backgroundColor]); }; exports.purchaseWithPlanVendorId = function (planId, offerId, contentId, success, error) { @@ -106,8 +129,9 @@ exports.purchasedSubscription = function (success, error) { exec(success, error, 'Purchasely', 'purchasedSubscription', []); }; -exports.isDeeplinkHandled = function (deepLink, success, error) { - exec(success, error, 'Purchasely', 'isDeeplinkHandled', [deepLink]); +// Purchasely 6.0: returns whether the deeplink was handled by Purchasely. +exports.handleDeeplink = function (deepLink, success, error) { + exec(success, error, 'Purchasely', 'handleDeeplink', [deepLink]); }; exports.allProducts = function (success, error) { @@ -122,12 +146,53 @@ exports.productWithIdentifier = function (productId, success) { exec(success, defaultError, 'Purchasely', 'productWithIdentifier', [productId]); }; -exports.setPaywallActionInterceptor = function (success) { - exec(success, defaultError, 'Purchasely', 'setPaywallActionInterceptor', []); -}; +// Purchasely 6.0: per-action interceptor. Registers a handler for a single +// action kind (see Purchasely.PresentationAction). The handler receives +// (info, parameters) and returns — or resolves to — a Purchasely.InterceptResult +// ('success' | 'failed' | 'notHandled'). Registering a kind again replaces its +// handler. This maps 1:1 onto the native v6 SDK, which intercepts per action. +var _actionInterceptors = {}; // kind -> true (registered) -exports.onProcessAction = function (processAction) { - exec(() => {}, defaultError, 'Purchasely', 'onProcessAction', [processAction]); +function normalizeInterceptResult(result) { + if (result === 'success' || result === 'failed' || result === 'notHandled') return result; + return 'notHandled'; +} + +exports.interceptAction = function (kind, handler) { + exports.removeActionInterceptor(kind); + _actionInterceptors[kind] = true; + exec(function (event) { + // Native registers one interceptor per kind, so events only arrive for + // this kind; guard anyway. `callbackId` ties the async reply back to the + // exact intercepted invocation, so concurrent intercepts never clobber. + if (!event || event.action !== kind) return; + Promise.resolve() + .then(function () { return handler(event.info || null, event.parameters || null); }) + .then(function (result) { + exec(function () {}, defaultError, 'Purchasely', 'completeActionInterceptor', + [event.callbackId, normalizeInterceptResult(result)]); + }) + .catch(function () { + exec(function () {}, defaultError, 'Purchasely', 'completeActionInterceptor', + [event.callbackId, 'failed']); + }); + }, defaultError, 'Purchasely', 'registerActionInterceptor', [kind]); +}; + +// Purchasely 6.0: stop intercepting a single action kind. +exports.removeActionInterceptor = function (kind, success, error) { + delete _actionInterceptors[kind]; + exec(function () {}, defaultError, 'Purchasely', 'unregisterActionInterceptor', [kind]); + if (success) setTimeout(success, 0); +}; + +// Purchasely 6.0: stop intercepting every registered action kind. +exports.removeAllActionInterceptors = function (success, error) { + Object.keys(_actionInterceptors).forEach(function (kind) { + delete _actionInterceptors[kind]; + exec(function () {}, defaultError, 'Purchasely', 'unregisterActionInterceptor', [kind]); + }); + if (success) setTimeout(success, 0); }; exports.userDidConsumeSubscriptionContent = function () { @@ -146,18 +211,16 @@ exports.setLanguage = function (language) { exec(() => {}, defaultError, 'Purchasely', 'setLanguage', [language]); }; -exports.showPresentation = function () { - exec(() => {}, defaultError, 'Purchasely', 'showPresentation', []); -}; - -exports.hidePresentation = function () { - exec(() => {}, defaultError, 'Purchasely', 'hidePresentation', []); -}; - +// Purchasely 6.0: close the displayed presentation. exports.closePresentation = function () { exec(() => {}, defaultError, 'Purchasely', 'closePresentation', []); }; +// Purchasely 6.0: navigate back within the displayed presentation. +exports.backPresentation = function () { + exec(() => {}, defaultError, 'Purchasely', 'backPresentation', []); +}; + exports.setUserAttributeWithString = function (key, value, processLegalBasis) { exec(() => {}, defaultError, 'Purchasely', 'setUserAttributeWithString', [key, value, processLegalBasis]); }; @@ -297,12 +360,15 @@ exports.PlanType = { unknown: 4 } +// Purchasely 6.0: running mode is passed by name (the native iOS and Android +// enums use different raw values). Native 6.0 exposes only observer and full. exports.RunningMode = { - paywallObserver: 2, - full: 3 + observer: 'observer', + full: 'full' } -exports.PaywallAction = { +// Purchasely 6.0: the paywall action kinds handled by interceptAction. +exports.PresentationAction = { close: 'close', close_all: 'close_all', login: 'login', @@ -315,6 +381,63 @@ exports.PaywallAction = { web_checkout: 'web_checkout' } +// Purchasely 6.0: result returned by an interceptAction handler after handling +// an intercepted paywall action. +exports.InterceptResult = { + success: 'success', + failed: 'failed', + notHandled: 'notHandled' +} + +// Purchasely 6.0: the type of a fetched presentation. +exports.PresentationType = { + normal: 0, + fallback: 1, + deactivated: 2, + client: 3 +} + +// Purchasely 6.0: why a presentation closed (delivered in the dismiss outcome). +// Matches the native PLYCloseReason wire contract (identical to the Flutter +// PLYCloseReason enum): `button`, `back_system`, `programmatic`. Android emits +// these directly; on iOS a swipe/interactive dismiss maps to `back_system`, and +// a close with no dismiss reason (e.g. after a purchase) omits the key. +exports.CloseReason = { + button: 'button', + backSystem: 'back_system', + programmatic: 'programmatic' +} + +// Purchasely 6.0: presentation display mode, passed in place of the former +// `isFullscreen` boolean to the present* methods. +exports.TransitionType = { + fullScreen: 'fullScreen', + modal: 'modal', + drawer: 'drawer', + popin: 'popin', + push: 'push', + inlinePaywall: 'inlinePaywall' +} + +// Purchasely 6.0: sizing unit for drawer/popin display modes. +exports.DimensionType = { + pixel: 'pixel', + percentage: 'percentage' +} + +// Purchasely 6.0: stores that can be enabled at start. +exports.Store = { + google: 'Google', + huawei: 'Huawei', + amazon: 'Amazon' +} + +// Purchasely 6.0: StoreKit version selection (iOS). +exports.StorekitVersion = { + storeKit1: 'storeKit1', + storeKit2: 'storeKit2' +} + exports.ThemeMode = { light: 0, dark: 1, @@ -324,4 +447,4 @@ exports.ThemeMode = { exports.UserAttributeAction = { ADD: 'add', REMOVE: 'remove' -} \ No newline at end of file +}