diff --git a/README.md b/README.md
index e7128346..059d6f30 100644
--- a/README.md
+++ b/README.md
@@ -23,7 +23,21 @@

-Adapty SDK is an open-source framework that makes implementing in-app subscriptions for React Native fast and easy. It’s 100% open-source and lightweight.
+Adapty SDK is an open-source framework that makes implementing in-app subscriptions for React Native fast and easy. It's 100% open-source and lightweight.
+
+## Table of Contents
+
+- [Why Adapty?](#why-adapty)
+- [Getting Started](#getting-started)
+- [Integrate IAPs within a few hours without server coding](#integrate-iaps-within-a-few-hours-without-server-coding)
+- [Design paywalls in the no-code builder](#design-paywalls-in-the-no-code-builder)
+- [Test paywalls & prices on React Native without app releases](#test-paywalls--prices-on-react-native-without-app-releases)
+- [Real-time analytics for your React Native app](#real-time-analytics-for-your-react-native-app)
+- [Mobile app monetization's largest community](#mobile-app-monetizations-largest-community)
+- [React Native Architecture Compatibility](#react-native-architecture-compatibility)
+- [Examples](#examples)
+- [Contributing](#contributing)
+- [License](#license)
## Why Adapty?
@@ -39,6 +53,27 @@ Adapty SDK is an open-source framework that makes implementing in-app subscripti
Talk to Us to Learn More
+## Getting Started
+
+### For React Native projects:
+
+```sh
+# using npm
+npm install react-native-adapty
+
+# or using yarn
+yarn add react-native-adapty
+```
+
+### For Expo projects:
+
+```sh
+npx expo install react-native-adapty
+npx expo prebuild
+```
+
+Read the [documentation](https://adapty.io/docs/sdk-installation-reactnative?utm_source=github&utm_medium=referral&utm_campaign=AdaptySDK-React-Native) to install and configure Adapty SDK. Set up purchases in hours instead of weeks :rocket:
+
## Integrate IAPs within a few hours without server coding
**Adapty handles everything, from free trials to refunds, in a simple, developer-friendly SDK.**
@@ -78,28 +113,6 @@ Ask questions, participate in discussions about Adapty-related topics, become a
-
-## Getting Started
-
-### For React Native projects:
-
-```sh
-# using npm
-npm install react-native-adapty
-
-# or using yarn
-yarn add react-native-adapty
-```
-
-### For Expo projects:
-
-```sh
-npx expo install react-native-adapty
-npx expo prebuild
-```
-
-Read the [documentation](https://adapty.io/docs/sdk-installation-reactnative?utm_source=github&utm_medium=referral&utm_campaign=AdaptySDK-React-Native) to install and configure Adapty SDK. Set up purchases in hours instead of weeks :rocket:
-
## React Native Architecture Compatibility
Adapty SDK is compatible with both **React Native's New Architecture** (including Turbo Modules) and the legacy architecture.
@@ -110,7 +123,8 @@ Adapty SDK is compatible with both **React Native's New Architecture** (includin
We provide several example applications with increasing complexity:
- **[BasicExample](./examples/BasicExample/)** (React Native) – Minimal setup example showing core SDK features.
-- **[FocusJournalExpo](./examples/FocusJournalExpo/)** (Expo) – Full-featured app with navigation and premium features.
+- **[ExpoGoWebMock](./examples/ExpoGoWebMock/)** (Expo Go / Expo Web) – Easiest to run (works in browser with mock mode, no Adapty key required). Demonstrates mock data usage for Expo Go/Web. Includes both custom paywall and Adapty Paywall Builder.
+- **[FocusJournalExpo](./examples/FocusJournalExpo/)** (Expo) – Simple app with premium features using Adapty Paywall Builder. Includes video guide.
- **[AdaptyDevtools](./examples/AdaptyDevtools/)** (React Native) – DevTools and bug reporting tool.
📹 **Watch our video guide** for step-by-step integration with the Focus Journal Expo example:
diff --git a/android/build.gradle b/android/build.gradle
index adf93041..2a192d01 100644
--- a/android/build.gradle
+++ b/android/build.gradle
@@ -110,10 +110,10 @@ def getExtOrDefault(name) {
def kotlin_version = getExtOrDefault('kotlinVersion')
dependencies {
- implementation platform('io.adapty:adapty-bom:3.15.1')
+ implementation platform('io.adapty:adapty-bom:3.15.2')
implementation 'io.adapty:android-sdk'
implementation 'io.adapty:android-ui'
- implementation 'io.adapty.internal:crossplatform:3.15.2'
+ implementation 'io.adapty.internal:crossplatform:3.15.3'
// Compatible with older and newer RN
//noinspection GradleDynamicVersion
diff --git a/cross_platform.yaml b/cross_platform.yaml
index 2d3d15e5..1ad5b9a5 100644
--- a/cross_platform.yaml
+++ b/cross_platform.yaml
@@ -1,5 +1,5 @@
$schema: "https://json-schema.org/draft/2020-12/schema"
-$id: "https://adapty.io/crossPlatform/3.15.1/schema"
+$id: "https://adapty.io/crossPlatform/3.15.2/schema"
title: "Cross Platform Format"
$requests:
@@ -412,6 +412,7 @@ $requests:
properties:
paywall: { $ref: "#/$defs/AdaptyPaywall" }
+
OpenWebPaywall.Response: #response
type: object
oneOf:
@@ -931,6 +932,7 @@ $defs:
google_enable_pending_prepaid_plans: { type: boolean, default: false }
google_local_access_level_allowed: { type: boolean, default: false }
ip_address_collection_disabled: { type: boolean, default: false }
+ clear_data_on_backup: { type: boolean, default: false }
server_cluster: { type: string, enum: ["default", "eu", "cn"] }
backend_proxy_host: { type: string }
backend_proxy_port: { type: integer }
diff --git a/examples/AdaptyDevtools/ios/Podfile.lock b/examples/AdaptyDevtools/ios/Podfile.lock
index 270c1abc..1885b832 100644
--- a/examples/AdaptyDevtools/ios/Podfile.lock
+++ b/examples/AdaptyDevtools/ios/Podfile.lock
@@ -1,19 +1,19 @@
PODS:
- - Adapty (3.15.1):
- - AdaptyLogger (= 3.15.1)
- - AdaptyUIBuilder (= 3.15.1)
- - AdaptyLogger (3.15.1)
- - AdaptyPlugin (3.15.1):
- - Adapty (= 3.15.1)
- - AdaptyLogger (= 3.15.1)
- - AdaptyUI (= 3.15.1)
- - AdaptyUIBuilder (= 3.15.1)
- - AdaptyUI (3.15.1):
- - Adapty (= 3.15.1)
- - AdaptyLogger (= 3.15.1)
- - AdaptyUIBuilder (= 3.15.1)
- - AdaptyUIBuilder (3.15.1):
- - AdaptyLogger (= 3.15.1)
+ - Adapty (3.15.3):
+ - AdaptyLogger (= 3.15.3)
+ - AdaptyUIBuilder (= 3.15.3)
+ - AdaptyLogger (3.15.3)
+ - AdaptyPlugin (3.15.3):
+ - Adapty (= 3.15.3)
+ - AdaptyLogger (= 3.15.3)
+ - AdaptyUI (= 3.15.3)
+ - AdaptyUIBuilder (= 3.15.3)
+ - AdaptyUI (3.15.3):
+ - Adapty (= 3.15.3)
+ - AdaptyLogger (= 3.15.3)
+ - AdaptyUIBuilder (= 3.15.3)
+ - AdaptyUIBuilder (3.15.3):
+ - AdaptyLogger (= 3.15.3)
- boost (1.84.0)
- DoubleConversion (1.1.6)
- fast_float (8.0.0)
@@ -1663,10 +1663,10 @@ PODS:
- React-RCTFBReactNativeSpec
- ReactCommon/turbomodule/core
- SocketRocket
- - react-native-adapty-sdk (3.15.0):
- - Adapty (= 3.15.1)
- - AdaptyPlugin (= 3.15.1)
- - AdaptyUI (= 3.15.1)
+ - react-native-adapty-sdk (3.15.1):
+ - Adapty (= 3.15.3)
+ - AdaptyPlugin (= 3.15.3)
+ - AdaptyUI (= 3.15.3)
- React
- react-native-safe-area-context (5.4.1):
- boost
@@ -2538,11 +2538,11 @@ EXTERNAL SOURCES:
:path: "../node_modules/react-native/ReactCommon/yoga"
SPEC CHECKSUMS:
- Adapty: bb2cc10a238b5d53025a0a969bc2a7ad6e9a48f5
- AdaptyLogger: 2d1a3cc8b8dee29dc171df753d6b6d45332bd399
- AdaptyPlugin: 79f4d39bcaa84ad3755fe0f0a07148abca2af4ed
- AdaptyUI: 56ff8ed7be49cbff9cdc29ddde230de60e115d4f
- AdaptyUIBuilder: 3204524ee377de2eddec478d51c3cf1f1e83ef1c
+ Adapty: 9d5c8378ddf71747e5182ae9dd3426b2a4dc274a
+ AdaptyLogger: 8a58745fe27ac991d2b2f05de57bfa9d932383ca
+ AdaptyPlugin: d832e606563b2cc3e77dbc4ca23e64725a3b21c5
+ AdaptyUI: 7b8515dade06d8a046826f024940c009bedb5f00
+ AdaptyUIBuilder: e94ea218d936f58f13123fda19f3c13fba5cdb7d
boost: 7e761d76ca2ce687f7cc98e698152abd03a18f90
DoubleConversion: cb417026b2400c8f53ae97020b2be961b59470cb
fast_float: b32c788ed9c6a8c584d114d0047beda9664e7cc6
@@ -2583,7 +2583,7 @@ SPEC CHECKSUMS:
React-logger: b69e65dc60f768e5509ac0cc27a360124bf70478
React-Mapbuffer: b48f9f3311fd0ec0f7a5dc39d707eff521fb5f38
React-microtasksnativemodule: d8568d0485a350c720c061ae835e09fc88c28715
- react-native-adapty-sdk: 39e2fb1607910e7d719e291281516864846792f1
+ react-native-adapty-sdk: 765bc4b65a425b4f77e370d9b8e33ddcd046c578
react-native-safe-area-context: 6775aa9089fa84b77abd7ebdcf45e224a2a2ad3e
React-NativeModulesApple: f10596688a03af66804cfbe61792be24a7888da8
React-oscompat: 7c0a341cc31e350da71ddf2e46de0a845d1d1626
diff --git a/package.json b/package.json
index d66ecca9..9ead9726 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package",
"name": "react-native-adapty",
- "version": "3.15.0",
+ "version": "3.15.1",
"description": "Adapty React Native SDK",
"license": "MIT",
"author": "Adapty team ",
diff --git a/react-native-adapty-sdk.podspec b/react-native-adapty-sdk.podspec
index bc404dc4..e60571bc 100644
--- a/react-native-adapty-sdk.podspec
+++ b/react-native-adapty-sdk.podspec
@@ -19,9 +19,9 @@ Pod::Spec.new do |s|
s.resources = "ios/**/*.{plist}"
s.requires_arc = true
- s.dependency "Adapty", "3.15.1"
- s.dependency "AdaptyUI", "3.15.1"
- s.dependency "AdaptyPlugin", "3.15.1"
+ s.dependency "Adapty", "3.15.3"
+ s.dependency "AdaptyUI", "3.15.3"
+ s.dependency "AdaptyPlugin", "3.15.3"
s.dependency "React"
end
diff --git a/src/__tests__/integration/adapty-handler/adapty-handler-bridge-event-samples.ts b/src/__tests__/integration/adapty-handler/adapty-handler-bridge-event-samples.ts
new file mode 100644
index 00000000..d70afc55
--- /dev/null
+++ b/src/__tests__/integration/adapty-handler/adapty-handler-bridge-event-samples.ts
@@ -0,0 +1,86 @@
+/**
+ * Bridge event samples for general Adapty handler events
+ *
+ * Real event data extracted from native logs for accurate testing.
+ *
+ * Use these samples for integration tests to verify event handling.
+ */
+
+/**
+ * Sample for Event.DidLoadLatestProfile with active premium subscription
+ * @see cross_platform.yaml#/$events/Event.DidLoadLatestProfile
+ */
+export const PROFILE_DID_LOAD_LATEST_WITH_PREMIUM = {
+ id: 'did_load_latest_profile',
+ profile: {
+ paid_access_levels: {
+ premium: {
+ activated_at: '2025-12-26T13:36:09.931000+0000',
+ expires_at: '2025-12-26T13:41:09.549000+0000',
+ id: 'premium',
+ is_active: true,
+ is_in_grace_period: false,
+ is_lifetime: false,
+ is_refund: false,
+ renewed_at: '2025-12-26T13:36:09.931000+0000',
+ store: 'play_store',
+ vendor_product_id: 'weekly.premium.599',
+ will_renew: true,
+ },
+ },
+ custom_attributes: {},
+ is_test_user: false,
+ non_subscriptions: {},
+ profile_id: 'cbdabead-697c-4804-9ea5-7ccaa83411c7',
+ subscriptions: {
+ 'weekly.premium.599': {
+ activated_at: '2025-12-26T13:36:09.931000+0000',
+ expires_at: '2025-12-26T13:41:09.549000+0000',
+ is_active: true,
+ is_in_grace_period: false,
+ is_lifetime: false,
+ is_refund: false,
+ is_sandbox: true,
+ renewed_at: '2025-12-26T13:36:09.931000+0000',
+ store: 'play_store',
+ vendor_original_transaction_id: 'GPA.3338-3241-1006-23335',
+ vendor_product_id: 'weekly.premium.599',
+ vendor_transaction_id: 'GPA.3338-3241-1006-23335',
+ will_renew: true,
+ },
+ },
+ segment_hash: 'not implemented',
+ timestamp: -1,
+ },
+} as const;
+
+/**
+ * Sample for Event.DidLoadLatestProfile with empty profile (no subscriptions)
+ * @see cross_platform.yaml#/$events/Event.DidLoadLatestProfile
+ */
+export const PROFILE_DID_LOAD_LATEST_EMPTY = {
+ id: 'did_load_latest_profile',
+ profile: {
+ profile_id: '8b79ec26-3f3d-482c-99e8-ec745710ef59',
+ customer_user_id: null,
+ segment_hash:
+ 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855',
+ paid_access_levels: {},
+ subscriptions: {},
+ non_subscriptions: {},
+ },
+} as const;
+
+/**
+ * Sample for Event.OnInstallationDetailsSuccess
+ * @see cross_platform.yaml#/$events/Event.OnInstallationDetailsSuccess
+ */
+export const INSTALLATION_DETAILS_SUCCESS = {
+ id: 'on_installation_details_success',
+ details: {
+ app_launch_count: 8,
+ payload: '{}',
+ install_time: '2025-12-16T12:08:41.041Z',
+ install_id: 'some-install-id',
+ },
+} as const;
diff --git a/src/__tests__/integration/ui/bridge-event-samples.ts b/src/__tests__/integration/ui/bridge-event-samples.ts
deleted file mode 100644
index 50746ef8..00000000
--- a/src/__tests__/integration/ui/bridge-event-samples.ts
+++ /dev/null
@@ -1,430 +0,0 @@
-/**
- * Bridge event samples in native format (snake_case)
- *
- * These samples represent events as they come from the native module.
- * Synthetic examples created based on cross_platform.yaml schema for types not present in logs.
- *
- * Use these samples for integration tests to verify event handling.
- */
-
-export const ONBOARDING_ANALYTICS_ONBOARDING_STARTED = {
- id: 'onboarding_on_analytics_action',
- view: {
- id: 'C2ECBFB4-5ADA-4E42-B129-49A7977175F3',
- variation_id: 'd7e60b9e-453a-42a1-8e80-145b3740cbbb',
- placement_id: 'test_stas0',
- },
- meta: {
- onboarding_id: '5e8e68b1-2696-4a5d-8069-4a5f9f4ac022',
- screen_cid: 'bGn6r0Fo',
- screen_index: 0,
- total_screens: 18,
- },
- event: {
- name: 'onboarding_started',
- },
-} as const;
-
-export const ONBOARDING_ANALYTICS_SCREEN_PRESENTED = {
- id: 'onboarding_on_analytics_action',
- view: {
- id: 'C2ECBFB4-5ADA-4E42-B129-49A7977175F3',
- variation_id: 'd7e60b9e-453a-42a1-8e80-145b3740cbbb',
- placement_id: 'test_stas0',
- },
- meta: {
- onboarding_id: '5e8e68b1-2696-4a5d-8069-4a5f9f4ac022',
- screen_cid: 'bGn6r0Fo',
- screen_index: 0,
- total_screens: 18,
- },
- event: {
- name: 'screen_presented',
- },
-} as const;
-
-export const ONBOARDING_ANALYTICS_SECOND_SCREEN_PRESENTED = {
- id: 'onboarding_on_analytics_action',
- view: {
- placement_id: 'test_stas0',
- id: 'C2ECBFB4-5ADA-4E42-B129-49A7977175F3',
- variation_id: 'd7e60b9e-453a-42a1-8e80-145b3740cbbb',
- },
- meta: {
- onboarding_id: '5e8e68b1-2696-4a5d-8069-4a5f9f4ac022',
- screen_cid: 'ryPxczcL',
- screen_index: 1,
- total_screens: 18,
- },
- event: {
- name: 'second_screen_presented',
- },
-} as const;
-
-export const ONBOARDING_ANALYTICS_NAVIGATION_FAILED = {
- id: 'onboarding_on_analytics_action',
- view: {
- id: 'C2ECBFB4-5ADA-4E42-B129-49A7977175F3',
- placement_id: 'test_stas0',
- variation_id: 'd7e60b9e-453a-42a1-8e80-145b3740cbbb',
- },
- meta: {
- onboarding_id: '5e8e68b1-2696-4a5d-8069-4a5f9f4ac022',
- screen_cid: 'X19x4kXO',
- screen_index: 17,
- total_screens: 18,
- },
- event: {
- name: 'navigation_failed',
- },
-} as const;
-
-export const ONBOARDING_STATE_UPDATED_TEXT_INPUT = {
- id: 'onboarding_on_state_updated_action',
- view: {
- id: 'C2ECBFB4-5ADA-4E42-B129-49A7977175F3',
- variation_id: 'd7e60b9e-453a-42a1-8e80-145b3740cbbb',
- placement_id: 'test_stas0',
- },
- meta: {
- onboarding_id: '5e8e68b1-2696-4a5d-8069-4a5f9f4ac022',
- screen_cid: 'any',
- screen_index: 11,
- total_screens: 18,
- },
- action: {
- element_id: 'name',
- element_type: 'input',
- value: {
- type: 'text',
- value: 'Test-nick',
- },
- },
-} as const;
-
-export const ONBOARDING_STATE_UPDATED_EMAIL_INPUT = {
- id: 'onboarding_on_state_updated_action',
- view: {
- id: 'C2ECBFB4-5ADA-4E42-B129-49A7977175F3',
- variation_id: 'd7e60b9e-453a-42a1-8e80-145b3740cbbb',
- placement_id: 'test_stas0',
- },
- meta: {
- onboarding_id: '5e8e68b1-2696-4a5d-8069-4a5f9f4ac022',
- screen_cid: 'any',
- screen_index: 12,
- total_screens: 18,
- },
- action: {
- element_id: 'email',
- element_type: 'input',
- value: {
- type: 'email',
- value: 'test@example.com',
- },
- },
-} as const;
-
-export const ONBOARDING_STATE_UPDATED_NUMBER_INPUT = {
- id: 'onboarding_on_state_updated_action',
- view: {
- id: 'C2ECBFB4-5ADA-4E42-B129-49A7977175F3',
- variation_id: 'd7e60b9e-453a-42a1-8e80-145b3740cbbb',
- placement_id: 'test_stas0',
- },
- meta: {
- onboarding_id: '5e8e68b1-2696-4a5d-8069-4a5f9f4ac022',
- screen_cid: 'any',
- screen_index: 13,
- total_screens: 18,
- },
- action: {
- element_id: 'age',
- element_type: 'input',
- value: {
- type: 'number',
- value: 25,
- },
- },
-} as const;
-
-export const ONBOARDING_STATE_UPDATED_SELECT_OPTION = {
- id: 'onboarding_on_state_updated_action',
- view: {
- id: 'C2ECBFB4-5ADA-4E42-B129-49A7977175F3',
- variation_id: 'd7e60b9e-453a-42a1-8e80-145b3740cbbb',
- placement_id: 'test_stas0',
- },
- meta: {
- onboarding_id: '5e8e68b1-2696-4a5d-8069-4a5f9f4ac022',
- screen_cid: 'any',
- screen_index: 4,
- total_screens: 18,
- },
- action: {
- element_id: 'experience_level',
- element_type: 'select',
- value: {
- id: 'intermediate',
- value: 'intermediate',
- label: 'Intermediate',
- },
- },
-} as const;
-
-export const ONBOARDING_STATE_UPDATED_MULTI_SELECT_SINGLE = {
- id: 'onboarding_on_state_updated_action',
- view: {
- variation_id: 'd7e60b9e-453a-42a1-8e80-145b3740cbbb',
- id: 'C2ECBFB4-5ADA-4E42-B129-49A7977175F3',
- placement_id: 'test_stas0',
- },
- meta: {
- onboarding_id: '5e8e68b1-2696-4a5d-8069-4a5f9f4ac022',
- screen_cid: 'S1Z2BFFy',
- screen_index: 5,
- total_screens: 18,
- },
- action: {
- value: [
- {
- id: 'QmdFI',
- value: 'skill-acquisition',
- label: 'Skill Acquisition',
- },
- ],
- element_id: 'goal',
- element_type: 'multi_select',
- },
-} as const;
-
-export const ONBOARDING_STATE_UPDATED_MULTI_SELECT_MULTIPLE = {
- id: 'onboarding_on_state_updated_action',
- view: {
- variation_id: 'd7e60b9e-453a-42a1-8e80-145b3740cbbb',
- id: 'C2ECBFB4-5ADA-4E42-B129-49A7977175F3',
- placement_id: 'test_stas0',
- },
- meta: {
- onboarding_id: '5e8e68b1-2696-4a5d-8069-4a5f9f4ac022',
- screen_cid: 'S1Z2BFFy',
- screen_index: 5,
- total_screens: 18,
- },
- action: {
- value: [
- {
- id: 'QmdFI',
- value: 'skill-acquisition',
- label: 'Skill Acquisition',
- },
- {
- id: 'abc123',
- value: 'productivity',
- label: 'Productivity',
- },
- ],
- element_id: 'goal',
- element_type: 'multi_select',
- },
-} as const;
-
-export const ONBOARDING_STATE_UPDATED_MULTI_SELECT_EMPTY = {
- id: 'onboarding_on_state_updated_action',
- view: {
- variation_id: 'd7e60b9e-453a-42a1-8e80-145b3740cbbb',
- id: 'C2ECBFB4-5ADA-4E42-B129-49A7977175F3',
- placement_id: 'test_stas0',
- },
- meta: {
- onboarding_id: '5e8e68b1-2696-4a5d-8069-4a5f9f4ac022',
- screen_cid: 'S1Z2BFFy',
- screen_index: 5,
- total_screens: 18,
- },
- action: {
- value: [],
- element_id: 'goal',
- element_type: 'multi_select',
- },
-} as const;
-
-export const ONBOARDING_STATE_UPDATED_DATE_PICKER_FULL = {
- id: 'onboarding_on_state_updated_action',
- view: {
- id: 'C2ECBFB4-5ADA-4E42-B129-49A7977175F3',
- variation_id: 'd7e60b9e-453a-42a1-8e80-145b3740cbbb',
- placement_id: 'test_stas0',
- },
- meta: {
- onboarding_id: '5e8e68b1-2696-4a5d-8069-4a5f9f4ac022',
- screen_cid: 'any',
- screen_index: 14,
- total_screens: 18,
- },
- action: {
- element_id: 'birth_date',
- element_type: 'date_picker',
- value: {
- day: 15,
- month: 6,
- year: 1990,
- },
- },
-} as const;
-
-export const ONBOARDING_STATE_UPDATED_DATE_PICKER_PARTIAL = {
- id: 'onboarding_on_state_updated_action',
- view: {
- id: 'C2ECBFB4-5ADA-4E42-B129-49A7977175F3',
- variation_id: 'd7e60b9e-453a-42a1-8e80-145b3740cbbb',
- placement_id: 'test_stas0',
- },
- meta: {
- onboarding_id: '5e8e68b1-2696-4a5d-8069-4a5f9f4ac022',
- screen_cid: 'any',
- screen_index: 14,
- total_screens: 18,
- },
- action: {
- element_id: 'birth_year',
- element_type: 'date_picker',
- value: {
- year: 1995,
- },
- },
-} as const;
-
-export const ONBOARDING_DID_FINISH_LOADING = {
- id: 'onboarding_did_finish_loading',
- view: {
- placement_id: 'test_stas0',
- id: 'C2ECBFB4-5ADA-4E42-B129-49A7977175F3',
- variation_id: 'd7e60b9e-453a-42a1-8e80-145b3740cbbb',
- },
- meta: {
- screen_cid: 'bGn6r0Fo',
- screen_index: 0,
- onboarding_id: '5e8e68b1-2696-4a5d-8069-4a5f9f4ac022',
- total_screens: 18,
- },
-} as const;
-
-export const ONBOARDING_PAYWALL_ACTION = {
- id: 'onboarding_on_paywall_action',
- view: {
- id: '00176648-6F60-44E8-999B-6547130D8AED',
- variation_id: '2c1390e1-e195-4f59-8fa0-5830e5d6f10b',
- placement_id: 'test_anna0',
- },
- meta: {
- onboarding_id: 'b0974453-633a-419a-870f-4f1f4bb97db1',
- screen_cid: '9p61aqIB',
- screen_index: 2,
- total_screens: 3,
- },
- action_id: 'test_anna2',
-} as const;
-
-export const ONBOARDING_CLOSE_ACTION = {
- id: 'onboarding_on_close_action',
- view: {
- variation_id: '2c1390e1-e195-4f59-8fa0-5830e5d6f10b',
- placement_id: 'test_anna0',
- id: '5F64F31E-9BC7-4733-8BDE-A546088BDE4E',
- },
- meta: {
- onboarding_id: 'b0974453-633a-419a-870f-4f1f4bb97db1',
- screen_cid: '9p61aqIB',
- screen_index: 2,
- total_screens: 3,
- },
- action_id: '',
-} as const;
-
-export const ONBOARDING_CUSTOM_ACTION = {
- id: 'onboarding_on_custom_action',
- view: {
- placement_id: 'test_anna0',
- variation_id: '2c1390e1-e195-4f59-8fa0-5830e5d6f10b',
- id: '5F64F31E-9BC7-4733-8BDE-A546088BDE4E',
- },
- meta: {
- onboarding_id: 'b0974453-633a-419a-870f-4f1f4bb97db1',
- screen_cid: '9p61aqIB',
- screen_index: 2,
- total_screens: 3,
- },
- action_id: 'id123',
-} as const;
-
-export const ONBOARDING_ERROR_EVENT = {
- id: 'onboarding_did_fail_with_error',
- view: {
- variation_id: '2c1390e1-e195-4f59-8fa0-5830e5d6f10b',
- placement_id: 'test_anna0',
- id: '4C98DF36-8A6F-4CA4-8545-065DD2C369AE',
- },
- error: {
- adaptyCode: 4200,
- message:
- 'AdaptyUIError.webKit (Code: 4200): Internal WebKit error occurred - Error Domain=NSURLErrorDomain Code=-1005 "The network connection was lost."',
- detail:
- 'An internal WebKit error occurred: Error Domain=NSURLErrorDomain Code=-1005 "The network connection was lost."',
- },
-} as const;
-
-export const PROFILE_DID_LOAD_LATEST_PROFILE = {
- id: 'did_load_latest_profile',
- profile: {
- profile_id: '8b79ec26-3f3d-482c-99e8-ec745710ef59',
- customer_user_id: null,
- segment_hash:
- 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855',
- paid_access_levels: {},
- subscriptions: {},
- non_subscriptions: {},
- },
-} as const;
-
-export const INSTALLATION_DETAILS_SUCCESS = {
- id: 'on_installation_details_success',
- details: {
- app_launch_count: 8,
- payload: '{}',
- install_time: '2025-12-16T12:08:41.041Z',
- install_id: 'some-install-id',
- },
-} as const;
-
-/**
- * All event types collected from logs
- */
-export const EVENT_TYPES = {
- ONBOARDING_ANALYTICS: 'onboarding_on_analytics_action',
- ONBOARDING_STATE_UPDATED: 'onboarding_on_state_updated_action',
- ONBOARDING_FINISHED_LOADING: 'onboarding_did_finish_loading',
- ONBOARDING_PAYWALL_ACTION: 'onboarding_on_paywall_action',
- ONBOARDING_CLOSE_ACTION: 'onboarding_on_close_action',
- ONBOARDING_CUSTOM_ACTION: 'onboarding_on_custom_action',
- ONBOARDING_ERROR: 'onboarding_did_fail_with_error',
- PROFILE_LOADED: 'did_load_latest_profile',
- INSTALLATION_DETAILS: 'on_installation_details_success',
-} as const;
-
-/**
- * View metadata that appears in all onboarding events
- */
-export const COMMON_VIEW_DATA = {
- id: 'C2ECBFB4-5ADA-4E42-B129-49A7977175F3',
- placement_id: 'test_stas0',
- variation_id: 'd7e60b9e-453a-42a1-8e80-145b3740cbbb',
-} as const;
-
-/**
- * Onboarding metadata
- */
-export const COMMON_ONBOARDING_META = {
- onboardingId: '5e8e68b1-2696-4a5d-8069-4a5f9f4ac022',
- totalScreens: 18,
-} as const;
diff --git a/src/__tests__/integration/ui/event-emitter.utils.ts b/src/__tests__/integration/ui/onboarding/event-emitter.utils.ts
similarity index 100%
rename from src/__tests__/integration/ui/event-emitter.utils.ts
rename to src/__tests__/integration/ui/onboarding/event-emitter.utils.ts
diff --git a/src/__tests__/integration/ui/onboarding-bridge-event-samples.ts b/src/__tests__/integration/ui/onboarding/onboarding-bridge-event-samples.ts
similarity index 82%
rename from src/__tests__/integration/ui/onboarding-bridge-event-samples.ts
rename to src/__tests__/integration/ui/onboarding/onboarding-bridge-event-samples.ts
index fb1c1d17..196235b3 100644
--- a/src/__tests__/integration/ui/onboarding-bridge-event-samples.ts
+++ b/src/__tests__/integration/ui/onboarding/onboarding-bridge-event-samples.ts
@@ -1,13 +1,6 @@
/**
* Bridge event samples in native format (snake_case)
*
- * These samples represent events as they come from the native module.
- * Real examples collected from device logs in:
- * - native-event-data.json (session 1)
- * - native-event-data-2.json (session 2)
- * - native-event-data-3.json (session 3 - includes onClose and onCustom events)
- * - native-event-data-4.json (session 4 - includes onError event)
- *
* Synthetic examples created based on cross_platform.yaml schema for types not present in logs.
*
* Use these samples for integration tests to verify event handling.
@@ -22,7 +15,7 @@ export const ONBOARDING_ANALYTICS_ONBOARDING_STARTED = {
view: {
id: 'C2ECBFB4-5ADA-4E42-B129-49A7977175F3',
variation_id: 'd7e60b9e-453a-42a1-8e80-145b3740cbbb',
- placement_id: 'test_stas0',
+ placement_id: 'test_placement',
},
meta: {
onboarding_id: '5e8e68b1-2696-4a5d-8069-4a5f9f4ac022',
@@ -44,7 +37,7 @@ export const ONBOARDING_ANALYTICS_SCREEN_PRESENTED = {
view: {
id: 'C2ECBFB4-5ADA-4E42-B129-49A7977175F3',
variation_id: 'd7e60b9e-453a-42a1-8e80-145b3740cbbb',
- placement_id: 'test_stas0',
+ placement_id: 'test_placement',
},
meta: {
onboarding_id: '5e8e68b1-2696-4a5d-8069-4a5f9f4ac022',
@@ -66,7 +59,7 @@ export const ONBOARDING_ANALYTICS_WITH_ELEMENT_ID = {
view: {
id: 'C2ECBFB4-5ADA-4E42-B129-49A7977175F3',
variation_id: 'd7e60b9e-453a-42a1-8e80-145b3740cbbb',
- placement_id: 'test_stas0',
+ placement_id: 'test_placement',
},
meta: {
onboarding_id: '5e8e68b1-2696-4a5d-8069-4a5f9f4ac022',
@@ -87,7 +80,7 @@ export const ONBOARDING_ANALYTICS_WITH_ELEMENT_ID = {
export const ONBOARDING_ANALYTICS_SECOND_SCREEN_PRESENTED = {
id: 'onboarding_on_analytics_action',
view: {
- placement_id: 'test_stas0',
+ placement_id: 'test_placement',
id: 'C2ECBFB4-5ADA-4E42-B129-49A7977175F3',
variation_id: 'd7e60b9e-453a-42a1-8e80-145b3740cbbb',
},
@@ -110,7 +103,7 @@ export const ONBOARDING_ANALYTICS_NAVIGATION_FAILED = {
id: 'onboarding_on_analytics_action',
view: {
id: 'C2ECBFB4-5ADA-4E42-B129-49A7977175F3',
- placement_id: 'test_stas0',
+ placement_id: 'test_placement',
variation_id: 'd7e60b9e-453a-42a1-8e80-145b3740cbbb',
},
meta: {
@@ -133,7 +126,7 @@ export const ONBOARDING_STATE_UPDATED_TEXT_INPUT = {
view: {
id: 'C2ECBFB4-5ADA-4E42-B129-49A7977175F3',
variation_id: 'd7e60b9e-453a-42a1-8e80-145b3740cbbb',
- placement_id: 'test_stas0',
+ placement_id: 'test_placement',
},
meta: {
onboarding_id: '5e8e68b1-2696-4a5d-8069-4a5f9f4ac022',
@@ -160,7 +153,7 @@ export const ONBOARDING_STATE_UPDATED_EMAIL_INPUT = {
view: {
id: 'C2ECBFB4-5ADA-4E42-B129-49A7977175F3',
variation_id: 'd7e60b9e-453a-42a1-8e80-145b3740cbbb',
- placement_id: 'test_stas0',
+ placement_id: 'test_placement',
},
meta: {
onboarding_id: '5e8e68b1-2696-4a5d-8069-4a5f9f4ac022',
@@ -187,7 +180,7 @@ export const ONBOARDING_STATE_UPDATED_NUMBER_INPUT = {
view: {
id: 'C2ECBFB4-5ADA-4E42-B129-49A7977175F3',
variation_id: 'd7e60b9e-453a-42a1-8e80-145b3740cbbb',
- placement_id: 'test_stas0',
+ placement_id: 'test_placement',
},
meta: {
onboarding_id: '5e8e68b1-2696-4a5d-8069-4a5f9f4ac022',
@@ -214,7 +207,7 @@ export const ONBOARDING_STATE_UPDATED_SELECT_OPTION = {
view: {
id: 'C2ECBFB4-5ADA-4E42-B129-49A7977175F3',
variation_id: 'd7e60b9e-453a-42a1-8e80-145b3740cbbb',
- placement_id: 'test_stas0',
+ placement_id: 'test_placement',
},
meta: {
onboarding_id: '5e8e68b1-2696-4a5d-8069-4a5f9f4ac022',
@@ -242,7 +235,7 @@ export const ONBOARDING_STATE_UPDATED_MULTI_SELECT_SINGLE = {
view: {
variation_id: 'd7e60b9e-453a-42a1-8e80-145b3740cbbb',
id: 'C2ECBFB4-5ADA-4E42-B129-49A7977175F3',
- placement_id: 'test_stas0',
+ placement_id: 'test_placement',
},
meta: {
onboarding_id: '5e8e68b1-2696-4a5d-8069-4a5f9f4ac022',
@@ -272,7 +265,7 @@ export const ONBOARDING_STATE_UPDATED_MULTI_SELECT_MULTIPLE = {
view: {
variation_id: 'd7e60b9e-453a-42a1-8e80-145b3740cbbb',
id: 'C2ECBFB4-5ADA-4E42-B129-49A7977175F3',
- placement_id: 'test_stas0',
+ placement_id: 'test_placement',
},
meta: {
onboarding_id: '5e8e68b1-2696-4a5d-8069-4a5f9f4ac022',
@@ -307,7 +300,7 @@ export const ONBOARDING_STATE_UPDATED_MULTI_SELECT_EMPTY = {
view: {
variation_id: 'd7e60b9e-453a-42a1-8e80-145b3740cbbb',
id: 'C2ECBFB4-5ADA-4E42-B129-49A7977175F3',
- placement_id: 'test_stas0',
+ placement_id: 'test_placement',
},
meta: {
onboarding_id: '5e8e68b1-2696-4a5d-8069-4a5f9f4ac022',
@@ -331,7 +324,7 @@ export const ONBOARDING_STATE_UPDATED_DATE_PICKER_FULL = {
view: {
id: 'C2ECBFB4-5ADA-4E42-B129-49A7977175F3',
variation_id: 'd7e60b9e-453a-42a1-8e80-145b3740cbbb',
- placement_id: 'test_stas0',
+ placement_id: 'test_placement',
},
meta: {
onboarding_id: '5e8e68b1-2696-4a5d-8069-4a5f9f4ac022',
@@ -359,7 +352,7 @@ export const ONBOARDING_STATE_UPDATED_DATE_PICKER_PARTIAL = {
view: {
id: 'C2ECBFB4-5ADA-4E42-B129-49A7977175F3',
variation_id: 'd7e60b9e-453a-42a1-8e80-145b3740cbbb',
- placement_id: 'test_stas0',
+ placement_id: 'test_placement',
},
meta: {
onboarding_id: '5e8e68b1-2696-4a5d-8069-4a5f9f4ac022',
@@ -383,7 +376,7 @@ export const ONBOARDING_STATE_UPDATED_DATE_PICKER_PARTIAL = {
export const ONBOARDING_DID_FINISH_LOADING = {
id: 'onboarding_did_finish_loading',
view: {
- placement_id: 'test_stas0',
+ placement_id: 'test_placement',
id: 'C2ECBFB4-5ADA-4E42-B129-49A7977175F3',
variation_id: 'd7e60b9e-453a-42a1-8e80-145b3740cbbb',
},
@@ -474,66 +467,3 @@ export const ONBOARDING_ERROR_EVENT = {
'An internal WebKit error occurred: Error Domain=NSURLErrorDomain Code=-1005 "The network connection was lost."',
},
} as const;
-
-/**
- * Sample for Event.DidLoadLatestProfile
- * @see cross_platform.yaml#/$events/Event.DidLoadLatestProfile
- */
-export const PROFILE_DID_LOAD_LATEST_PROFILE = {
- id: 'did_load_latest_profile',
- profile: {
- profile_id: '8b79ec26-3f3d-482c-99e8-ec745710ef59',
- customer_user_id: null,
- segment_hash:
- 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855',
- paid_access_levels: {},
- subscriptions: {},
- non_subscriptions: {},
- },
-} as const;
-
-/**
- * Sample for Event.OnInstallationDetailsSuccess
- * @see cross_platform.yaml#/$events/Event.OnInstallationDetailsSuccess
- */
-export const INSTALLATION_DETAILS_SUCCESS = {
- id: 'on_installation_details_success',
- details: {
- app_launch_count: 8,
- payload: '{}',
- install_time: '2025-12-16T12:08:41.041Z',
- install_id: 'some-install-id',
- },
-} as const;
-
-/**
- * All event types collected from logs
- */
-export const EVENT_TYPES = {
- ONBOARDING_ANALYTICS: 'onboarding_on_analytics_action',
- ONBOARDING_STATE_UPDATED: 'onboarding_on_state_updated_action',
- ONBOARDING_FINISHED_LOADING: 'onboarding_did_finish_loading',
- ONBOARDING_PAYWALL_ACTION: 'onboarding_on_paywall_action',
- ONBOARDING_CLOSE_ACTION: 'onboarding_on_close_action',
- ONBOARDING_CUSTOM_ACTION: 'onboarding_on_custom_action',
- ONBOARDING_ERROR: 'onboarding_did_fail_with_error',
- PROFILE_LOADED: 'did_load_latest_profile',
- INSTALLATION_DETAILS: 'on_installation_details_success',
-} as const;
-
-/**
- * View metadata that appears in all onboarding events
- */
-export const COMMON_VIEW_DATA = {
- id: 'C2ECBFB4-5ADA-4E42-B129-49A7977175F3',
- placement_id: 'test_stas0',
- variation_id: 'd7e60b9e-453a-42a1-8e80-145b3740cbbb',
-} as const;
-
-/**
- * Onboarding metadata
- */
-export const COMMON_ONBOARDING_META = {
- onboardingId: '5e8e68b1-2696-4a5d-8069-4a5f9f4ac022',
- totalScreens: 18,
-} as const;
diff --git a/src/__tests__/integration/ui/onboarding-view-controller-events.test.ts b/src/__tests__/integration/ui/onboarding/onboarding-view-controller-events.test.ts
similarity index 99%
rename from src/__tests__/integration/ui/onboarding-view-controller-events.test.ts
rename to src/__tests__/integration/ui/onboarding/onboarding-view-controller-events.test.ts
index 10232220..2fa5004b 100644
--- a/src/__tests__/integration/ui/onboarding-view-controller-events.test.ts
+++ b/src/__tests__/integration/ui/onboarding/onboarding-view-controller-events.test.ts
@@ -6,7 +6,7 @@ import { OnboardingEventHandlers } from '@/ui/types';
import {
createOnboardingViewController,
cleanupOnboardingViewController,
-} from './setup.utils';
+} from '../setup.utils';
import {
emitOnboardingCloseEvent,
emitOnboardingAnalyticsEvent,
diff --git a/src/__tests__/integration/ui/paywall/android-paywall-bridge-event-samples.ts b/src/__tests__/integration/ui/paywall/android-paywall-bridge-event-samples.ts
new file mode 100644
index 00000000..e0f66c9d
--- /dev/null
+++ b/src/__tests__/integration/ui/paywall/android-paywall-bridge-event-samples.ts
@@ -0,0 +1,320 @@
+/**
+ * Android-specific bridge event samples in native format (snake_case)
+ *
+ * Real Android event data extracted from native logs for accurate testing.
+ * iOS-specific fields have been removed, Android-specific fields are included.
+ *
+ * Use these samples for integration tests to verify Android-specific event handling.
+ */
+
+/**
+ * Sample for PaywallViewEvent.DidUserAction with system_back action (Android)
+ * @see cross_platform.yaml#/$events/PaywallViewEvent.DidUserAction
+ */
+export const ANDROID_PAYWALL_USER_ACTION_SYSTEM_BACK = {
+ view: {
+ id: '99cc6779-cf80-4fca-ae3c-9d7a7bca0fed',
+ placement_id: 'AdaptyRnSdkExample1',
+ variation_id: 'a24a2d05-93fe-4bcc-a76e-eef7690a436c',
+ },
+ action: {
+ type: 'system_back',
+ },
+ id: 'paywall_view_did_perform_action',
+} as const;
+
+/**
+ * Sample for PaywallViewEvent.DidPurchase with successful purchase result (Android)
+ * @see cross_platform.yaml#/$events/PaywallViewEvent.DidPurchase
+ */
+export const ANDROID_PAYWALL_PURCHASE_COMPLETED_SUCCESS = {
+ view: {
+ id: '88df97f8-ca94-43a4-bd4a-1749a89988e8',
+ placement_id: 'AdaptyRnSdkExample1',
+ variation_id: 'a24a2d05-93fe-4bcc-a76e-eef7690a436c',
+ },
+ product: {
+ access_level_id: 'premium',
+ localized_description: 'sdfg',
+ localized_title: 'sdfg',
+ payload_data:
+ 'eyJjdXJyZW5jeV9jb2RlIjoiRVVSIiwicHJpY2VfYW1vdW50X21pY3JvcyI6MTE5OTAwMDAsInN1\nYnNjcmlwdGlvbl9kYXRhIjp7ImJhc2VfcGxhbl9pZCI6IndlZWtseS1wcmVtaXVtLTU5OS1iYXNl\nIiwib2ZmZXJfaWQiOm51bGx9LCJ0eXBlIjoic3VicyJ9\n',
+ paywall_ab_test_name: 'AdaptyRnSdkExample1',
+ paywall_name: 'AdaptyRnSdkExample1',
+ price: {
+ amount: 11.99,
+ currency_code: 'EUR',
+ currency_symbol: '€',
+ localized_string: '€11.99',
+ },
+ product_type: 'weekly',
+ subscription: {
+ base_plan_id: 'weekly-premium-599-base',
+ localized_period: '1 week',
+ offer_id: 'intro-pay-up-front-weekly-premium',
+ offer_tags: [],
+ renewal_type: 'autorenewable',
+ period: {
+ number_of_units: 1,
+ unit: 'week',
+ },
+ },
+ paywall_variation_id: 'a24a2d05-93fe-4bcc-a76e-eef7690a436c',
+ vendor_product_id: 'weekly.premium.599',
+ web_purchase_url:
+ 'http://paywalls-14c3d623-2f3a-455a-aa86-ef83dff6913b.fnlfx.com/trest2',
+ paywall_product_index: 0,
+ adapty_product_id: 'b136422f-8153-402a-afbb-986929c68f6a',
+ },
+ purchased_result: {
+ type: 'success',
+ google_purchase_token:
+ 'abcdefghijklmnopqrs.AO-J1Oy0BKdJa02G4rK5G7jxhSbdajFmsy3FSHTqLUwE1rApPKMSJaY_gZg6aIVgxPO10_GRD94ZCSKvoAp3EkqDLRLQ45W7-Q',
+ profile: {
+ paid_access_levels: {
+ premium: {
+ activated_at: '2025-12-26T13:36:09.931000+0000',
+ expires_at: '2025-12-26T13:41:09.549000+0000',
+ id: 'premium',
+ is_active: true,
+ is_in_grace_period: false,
+ is_lifetime: false,
+ is_refund: false,
+ renewed_at: '2025-12-26T13:36:09.931000+0000',
+ store: 'play_store',
+ vendor_product_id: 'weekly.premium.599',
+ will_renew: true,
+ },
+ },
+ custom_attributes: {},
+ is_test_user: false,
+ non_subscriptions: {},
+ profile_id: 'cbdabead-697c-4804-9ea5-7ccaa83411c7',
+ subscriptions: {
+ 'weekly.premium.599': {
+ activated_at: '2025-12-26T13:36:09.931000+0000',
+ expires_at: '2025-12-26T13:41:09.549000+0000',
+ is_active: true,
+ is_in_grace_period: false,
+ is_lifetime: false,
+ is_refund: false,
+ is_sandbox: true,
+ renewed_at: '2025-12-26T13:36:09.931000+0000',
+ store: 'play_store',
+ vendor_original_transaction_id: 'GPA.3338-3241-1006-23335',
+ vendor_product_id: 'weekly.premium.599',
+ vendor_transaction_id: 'GPA.3338-3241-1006-23335',
+ will_renew: true,
+ },
+ },
+ segment_hash: 'not implemented',
+ timestamp: -1,
+ },
+ },
+ id: 'paywall_view_did_finish_purchase',
+} as const;
+
+/**
+ * Sample for PaywallViewEvent.DidFailPurchase (Android)
+ * @see cross_platform.yaml#/$events/PaywallViewEvent.DidFailPurchase
+ */
+export const ANDROID_PAYWALL_PURCHASE_FAILED = {
+ view: {
+ id: '2064bb24-39e4-4c06-a9aa-4417357edfb4',
+ placement_id: 'test_placement',
+ variation_id: '61d30b4d-d92e-4494-8d78-f3b0f4356fae',
+ },
+ product: {
+ access_level_id: 'premium',
+ localized_description: 'Description',
+ localized_title: 'Title',
+ payload_data:
+ 'eyJjdXJyZW5jeV9jb2RlIjoiRVVSIiwicHJpY2VfYW1vdW50X21pY3JvcyI6MTM5OTAwMDAsInN1\nYmNjcmlwdGlvbl9kYXRhIjp7ImJhc2VfcGxhbl9pZCI6Im1vbnRobHktcHJlbWl1bS05OTktYmFz\nZSIsIm9mZmVyX2lkIjpudWxsfSwidHlwZSI6InN1YnMifQ==\n',
+ paywall_ab_test_name: 'test_restore_button',
+ paywall_name: 'test_restore_button',
+ price: {
+ amount: 13.99,
+ currency_code: 'EUR',
+ currency_symbol: '€',
+ localized_string: '€13.99',
+ },
+ product_type: 'monthly',
+ subscription: {
+ base_plan_id: 'monthly-premium-999-base',
+ localized_period: '1 month',
+ offer_tags: [],
+ renewal_type: 'autorenewable',
+ period: {
+ number_of_units: 1,
+ unit: 'month',
+ },
+ },
+ paywall_variation_id: '61d30b4d-d92e-4494-8d78-f3b0f4356fae',
+ vendor_product_id: 'monthly.premium.999',
+ paywall_product_index: 0,
+ adapty_product_id: 'ac281b85-9294-4109-b9f1-4ab66b52d263',
+ },
+ error: {
+ adapty_code: 103,
+ message: 'Play Market request failed on purchases updated: responseCode=3',
+ },
+ id: 'paywall_view_did_fail_purchase',
+} as const;
+
+/**
+ * Sample for PaywallViewEvent.WillPurchase (Android)
+ * @see cross_platform.yaml#/$events/PaywallViewEvent.WillPurchase
+ */
+export const ANDROID_PAYWALL_PURCHASE_STARTED = {
+ product: {
+ subscription: {
+ base_plan_id: 'yearly-premium-6999-base',
+ localized_period: '1 year',
+ offer_tags: [],
+ renewal_type: 'autorenewable',
+ period: {
+ unit: 'year',
+ number_of_units: 1,
+ },
+ },
+ region_code: 'US',
+ access_level_id: 'premium',
+ localized_title: '1 Year Premium',
+ vendor_product_id: 'yearly.premium.6999',
+ localized_description: '1 Year Premium Description',
+ paywall_ab_test_name: 'rt.Short.Overlay.Video.DarkMode.Toggle2 (Copy)',
+ price: {
+ currency_code: 'USD',
+ currency_symbol: '$',
+ localized_string: '$69.99',
+ amount: 69.99,
+ },
+ paywall_product_index: 1,
+ paywall_name: 'rt.Short.Overlay.Video.DarkMode.Toggle2 (Copy)',
+ product_type: 'annual',
+ paywall_variation_id: '3968c273-f247-4b9f-bd90-305be39d6414',
+ adapty_product_id: '4f930955-b0e4-47c3-8bb9-abd1bbdccabd',
+ },
+ view: {
+ variation_id: '3968c273-f247-4b9f-bd90-305be39d6414',
+ placement_id: '3968c273-f247-4b9f-bd90-305be39d6414',
+ id: '9EC086AC-BE4F-4FB2-AABE-8AD31AF03BDF',
+ },
+ id: 'paywall_view_did_start_purchase',
+} as const;
+
+/**
+ * Sample for PaywallViewEvent.DidRestorePurchase with successful restore (Android)
+ * @see cross_platform.yaml#/$events/PaywallViewEvent.DidRestorePurchase
+ */
+export const ANDROID_PAYWALL_RESTORE_COMPLETED_SUCCESS = {
+ view: {
+ id: '3980be37-7a25-4c38-aace-68ee46b2927c',
+ placement_id: 'test_placement',
+ variation_id: '61d30b4d-d92e-4494-8d78-f3b0f4356fae',
+ },
+ profile: {
+ paid_access_levels: {
+ premium: {
+ activated_at: '2025-12-26T14:15:01.977000+0000',
+ active_introductory_offer_type: 'free_trial',
+ expires_at: '2025-12-26T14:18:01.719000+0000',
+ id: 'premium',
+ is_active: true,
+ is_in_grace_period: false,
+ is_lifetime: false,
+ is_refund: false,
+ offer_id: 'intro-free-trial-yearly',
+ renewed_at: '2025-12-26T14:15:01.977000+0000',
+ store: 'play_store',
+ vendor_product_id: 'yearly.premium.6999',
+ will_renew: true,
+ },
+ },
+ custom_attributes: {},
+ is_test_user: false,
+ non_subscriptions: {},
+ profile_id: 'd200c008-13fd-4557-9c51-cff73b45a7f2',
+ subscriptions: {
+ 'yearly.premium.6999': {
+ activated_at: '2025-12-26T14:15:01.977000+0000',
+ active_introductory_offer_type: 'free_trial',
+ expires_at: '2025-12-26T14:18:01.719000+0000',
+ is_active: true,
+ is_in_grace_period: false,
+ is_lifetime: false,
+ is_refund: false,
+ is_sandbox: true,
+ offer_id: 'intro-free-trial-yearly',
+ renewed_at: '2025-12-26T14:15:01.977000+0000',
+ store: 'play_store',
+ vendor_original_transaction_id: 'GPA.3372-6866-7337-08302',
+ vendor_product_id: 'yearly.premium.6999',
+ vendor_transaction_id: 'GPA.3372-6866-7337-08302',
+ will_renew: true,
+ },
+ 'weekly.premium.599': {
+ activated_at: '2025-12-26T13:36:09.931000+0000',
+ cancellation_reason: 'unknown',
+ expires_at: '2025-12-26T13:41:09.549000+0000',
+ is_active: false,
+ is_in_grace_period: false,
+ is_lifetime: false,
+ is_refund: false,
+ is_sandbox: true,
+ renewed_at: '2025-12-26T13:36:09.931000+0000',
+ store: 'play_store',
+ vendor_original_transaction_id: 'GPA.3338-3241-1006-23335',
+ vendor_product_id: 'weekly.premium.599',
+ vendor_transaction_id: 'GPA.3338-3241-1006-23335',
+ will_renew: false,
+ },
+ },
+ segment_hash: 'not implemented',
+ timestamp: -1,
+ },
+ id: 'paywall_view_did_finish_restore',
+} as const;
+
+/**
+ * Sample for PaywallViewEvent.DidFinishWebPaymentNavigation (Android)
+ * @see cross_platform.yaml#/$events/PaywallViewEvent.DidFinishWebPaymentNavigation
+ */
+export const ANDROID_PAYWALL_WEB_PAYMENT_NAVIGATION_FINISHED = {
+ view: {
+ id: '2442A0E9-FB7F-4369-87BB-61C80222AFA1',
+ variation_id: '5b4f588f-1ea3-4000-9de9-0e82e2fe7a48',
+ placement_id: '5b4f588f-1ea3-4000-9de9-0e82e2fe7a48',
+ },
+ product: {
+ region_code: 'US',
+ product_type: 'semiannual',
+ paywall_name: 'rt.web',
+ vendor_product_id: 'sixmonth.premium.999',
+ localized_title: 'Six Months Premium',
+ web_purchase_url: 'https://example.com',
+ access_level_id: 'premium',
+ paywall_ab_test_name: 'rt.web',
+ subscription: {
+ period: {
+ unit: 'month',
+ number_of_units: 6,
+ },
+ base_plan_id: 'sixmonth-premium-999-base',
+ localized_period: '6 months',
+ offer_tags: [],
+ renewal_type: 'autorenewable',
+ },
+ localized_description: 'Six Months Premium Description',
+ adapty_product_id: '0f2e86da-4d6f-435e-bc8f-9f2d1c265a27',
+ paywall_variation_id: '5b4f588f-1ea3-4000-9de9-0e82e2fe7a48',
+ price: {
+ currency_symbol: '$',
+ localized_string: '$14.99',
+ currency_code: 'USD',
+ amount: 14.99,
+ },
+ paywall_product_index: 0,
+ },
+ id: 'paywall_view_did_finish_web_payment_navigation',
+} as const;
diff --git a/src/__tests__/integration/ui/paywall/android-paywall-view-controller-events.test.ts b/src/__tests__/integration/ui/paywall/android-paywall-view-controller-events.test.ts
new file mode 100644
index 00000000..008d00ab
--- /dev/null
+++ b/src/__tests__/integration/ui/paywall/android-paywall-view-controller-events.test.ts
@@ -0,0 +1,398 @@
+import { Platform } from 'react-native';
+import { Adapty } from '@/adapty-handler';
+import { AdaptyError } from '@/adapty-error';
+import { ViewController } from '@/ui/view-controller';
+import { EventHandlers } from '@/ui/types';
+import type { AdaptySubscription, AdaptyAccessLevel } from '@/types';
+import {
+ createPaywallViewController,
+ cleanupPaywallViewController,
+} from '../setup.utils';
+import {
+ emitPaywallUserActionEvent,
+ emitPaywallPurchaseStartedEvent,
+ emitPaywallPurchaseCompletedEvent,
+ emitPaywallPurchaseFailedEvent,
+ emitPaywallRestoreCompletedEvent,
+ emitPaywallWebPaymentNavigationFinishedEvent,
+} from './paywall-event-emitter.utils';
+import {
+ ANDROID_PAYWALL_USER_ACTION_SYSTEM_BACK,
+ ANDROID_PAYWALL_PURCHASE_COMPLETED_SUCCESS,
+ ANDROID_PAYWALL_PURCHASE_FAILED,
+ ANDROID_PAYWALL_PURCHASE_STARTED,
+ ANDROID_PAYWALL_RESTORE_COMPLETED_SUCCESS,
+ ANDROID_PAYWALL_WEB_PAYMENT_NAVIGATION_FINISHED,
+} from './android-paywall-bridge-event-samples';
+
+// Override Platform.OS for Android tests
+const originalOS = Platform.OS;
+const originalSelect = Platform.select;
+
+beforeAll(() => {
+ Platform.OS = 'android';
+ Platform.select = jest.fn((obj: any) => obj.android || obj.default);
+});
+
+afterAll(() => {
+ Platform.OS = originalOS;
+ Platform.select = originalSelect;
+});
+
+describe('ViewController - onAndroidSystemBack event (Android fields)', () => {
+ let adapty: Adapty;
+ let view: ViewController;
+
+ beforeEach(async () => {
+ const result = await createPaywallViewController();
+ adapty = result.adapty;
+ view = result.view;
+ });
+
+ afterEach(() => {
+ cleanupPaywallViewController(view, adapty);
+ });
+
+ it('should call onAndroidSystemBack handler when system back is pressed', async () => {
+ const handler: jest.MockedFunction =
+ jest.fn().mockReturnValue(false);
+
+ view.setEventHandlers({ onAndroidSystemBack: handler });
+
+ const viewId = (view as any).id;
+ const sample = ANDROID_PAYWALL_USER_ACTION_SYSTEM_BACK;
+
+ emitPaywallUserActionEvent(viewId, 'system_back', undefined, sample.view);
+
+ expect(handler).toHaveBeenCalledTimes(1);
+ expect(handler).toHaveBeenCalledWith();
+ });
+
+ it('should auto-dismiss paywall when onAndroidSystemBack event is emitted with default handler', async () => {
+ const viewId = (view as any).id;
+ const sample = ANDROID_PAYWALL_USER_ACTION_SYSTEM_BACK;
+
+ const dismissSpy = jest.spyOn(view, 'dismiss').mockResolvedValue(undefined);
+
+ emitPaywallUserActionEvent(viewId, 'system_back', undefined, sample.view);
+
+ expect(dismissSpy).toHaveBeenCalledTimes(1);
+
+ dismissSpy.mockRestore();
+ });
+
+ it('should allow overriding default onAndroidSystemBack handler', async () => {
+ const viewId = (view as any).id;
+ const sample = ANDROID_PAYWALL_USER_ACTION_SYSTEM_BACK;
+
+ const dismissSpy = jest.spyOn(view, 'dismiss').mockResolvedValue(undefined);
+
+ const customHandler = jest.fn().mockReturnValue(false);
+ view.setEventHandlers({ onAndroidSystemBack: customHandler });
+
+ emitPaywallUserActionEvent(viewId, 'system_back', undefined, sample.view);
+
+ expect(customHandler).toHaveBeenCalledTimes(1);
+ expect(dismissSpy).not.toHaveBeenCalled();
+
+ dismissSpy.mockRestore();
+ });
+});
+
+describe('ViewController - onPurchaseStarted event (Android fields)', () => {
+ let adapty: Adapty;
+ let view: ViewController;
+
+ beforeEach(async () => {
+ const result = await createPaywallViewController();
+ adapty = result.adapty;
+ view = result.view;
+ });
+
+ afterEach(() => {
+ cleanupPaywallViewController(view, adapty);
+ });
+
+ it('should call onPurchaseStarted handler with Android-specific subscription fields', async () => {
+ const handler: jest.MockedFunction =
+ jest.fn().mockReturnValue(false);
+
+ view.setEventHandlers({ onPurchaseStarted: handler });
+
+ const viewId = (view as any).id;
+ const sample = ANDROID_PAYWALL_PURCHASE_STARTED;
+
+ emitPaywallPurchaseStartedEvent(viewId, sample.product, sample.view);
+
+ expect(handler).toHaveBeenCalledTimes(1);
+ const [product] = handler.mock.calls[0]!;
+
+ // Verify Android-specific subscription fields
+ expect(product.subscription).toBeDefined();
+ if (product.subscription) {
+ expect(product.subscription.android).toBeDefined();
+ if (product.subscription.android) {
+ expect(product.subscription.android.renewalType).toBe('autorenewable');
+ expect(product.subscription.android.basePlanId).toBe(
+ 'yearly-premium-6999-base',
+ );
+ }
+
+ // Verify iOS-specific fields are NOT present
+ expect(product.subscription).not.toHaveProperty('ios');
+ if (product.subscription.offer) {
+ expect(product.subscription.offer).not.toHaveProperty('identifier');
+ expect(product.subscription.offer).not.toHaveProperty('phases');
+ }
+ }
+ // iOS property may exist but should be empty for Android products
+ if (product.ios) {
+ expect(Object.keys(product.ios)).toHaveLength(0);
+ }
+ });
+});
+
+describe('ViewController - onPurchaseCompleted event (Android fields)', () => {
+ let adapty: Adapty;
+ let view: ViewController;
+
+ beforeEach(async () => {
+ const result = await createPaywallViewController();
+ adapty = result.adapty;
+ view = result.view;
+ });
+
+ afterEach(() => {
+ cleanupPaywallViewController(view, adapty);
+ });
+
+ it('should call onPurchaseCompleted handler with Android-specific purchase result fields', async () => {
+ const handler: jest.MockedFunction =
+ jest.fn().mockReturnValue(false);
+
+ view.setEventHandlers({ onPurchaseCompleted: handler });
+
+ const viewId = (view as any).id;
+ const sample = ANDROID_PAYWALL_PURCHASE_COMPLETED_SUCCESS;
+
+ emitPaywallPurchaseCompletedEvent(
+ viewId,
+ sample.purchased_result,
+ sample.product,
+ sample.view,
+ );
+
+ expect(handler).toHaveBeenCalledTimes(1);
+ const [purchaseResult, product] = handler.mock.calls[0]!;
+
+ // Verify Android-specific purchase result fields
+ expect(purchaseResult.type).toBe('success');
+ if (purchaseResult.type === 'success') {
+ expect(purchaseResult.android).toBeDefined();
+ if (purchaseResult.android) {
+ expect(purchaseResult.android.purchaseToken).toBe(
+ sample.purchased_result.google_purchase_token,
+ );
+ expect(purchaseResult.android.purchaseToken).toContain('AO-J1Oy');
+ }
+
+ // Verify iOS-specific field is NOT present
+ expect(purchaseResult).not.toHaveProperty('ios');
+
+ // Verify Android store in profile
+ expect(purchaseResult.profile).toBeDefined();
+ if (purchaseResult.profile.subscriptions) {
+ const subscription = Object.values(
+ purchaseResult.profile.subscriptions,
+ )[0] as AdaptySubscription | undefined;
+ if (subscription) {
+ expect(subscription.store).toBe('play_store');
+ expect(subscription.vendorTransactionId).toMatch(/^GPA\./);
+ expect(subscription.vendorOriginalTransactionId).toMatch(/^GPA\./);
+ }
+ }
+ }
+
+ // Verify Android-specific product subscription fields
+ if (product.subscription?.android) {
+ expect(product.subscription.android.renewalType).toBe('autorenewable');
+ expect(product.subscription.android.basePlanId).toBe(
+ 'weekly-premium-599-base',
+ );
+ if (product.subscription.offer?.android) {
+ expect(product.subscription.offer.android.offerTags).toEqual([]);
+ }
+ }
+ });
+});
+
+describe('ViewController - onPurchaseFailed event (Android fields)', () => {
+ let adapty: Adapty;
+ let view: ViewController;
+
+ beforeEach(async () => {
+ const result = await createPaywallViewController();
+ adapty = result.adapty;
+ view = result.view;
+ });
+
+ afterEach(() => {
+ cleanupPaywallViewController(view, adapty);
+ });
+
+ it('should call onPurchaseFailed handler with Android-specific product fields', async () => {
+ const handler: jest.MockedFunction = jest
+ .fn()
+ .mockReturnValue(false);
+
+ view.setEventHandlers({ onPurchaseFailed: handler });
+
+ const viewId = (view as any).id;
+ const sample = ANDROID_PAYWALL_PURCHASE_FAILED;
+
+ emitPaywallPurchaseFailedEvent(
+ viewId,
+ sample.error,
+ sample.product,
+ sample.view,
+ );
+
+ expect(handler).toHaveBeenCalledTimes(1);
+ const [error, product] = handler.mock.calls[0]!;
+
+ expect(error).toBeInstanceOf(AdaptyError);
+ expect(error.adaptyCode).toBe(sample.error.adapty_code);
+
+ // Verify Android-specific subscription fields
+ expect(product.subscription).toBeDefined();
+ if (product.subscription?.android) {
+ expect(product.subscription.android.renewalType).toBe('autorenewable');
+ expect(product.subscription.android.basePlanId).toBe(
+ 'monthly-premium-999-base',
+ );
+ }
+
+ // Verify iOS-specific fields are NOT present
+ if (product.subscription) {
+ expect(product.subscription).not.toHaveProperty('ios');
+ }
+ // iOS property may exist but should be empty for Android products
+ if (product.ios) {
+ expect(Object.keys(product.ios)).toHaveLength(0);
+ }
+ });
+});
+
+describe('ViewController - onRestoreCompleted event (Android fields)', () => {
+ let adapty: Adapty;
+ let view: ViewController;
+
+ beforeEach(async () => {
+ const result = await createPaywallViewController();
+ adapty = result.adapty;
+ view = result.view;
+ });
+
+ afterEach(() => {
+ cleanupPaywallViewController(view, adapty);
+ });
+
+ it('should call onRestoreCompleted handler with Android-specific profile fields', async () => {
+ const handler: jest.MockedFunction =
+ jest.fn().mockReturnValue(false);
+
+ view.setEventHandlers({ onRestoreCompleted: handler });
+
+ const viewId = (view as any).id;
+ const sample = ANDROID_PAYWALL_RESTORE_COMPLETED_SUCCESS;
+
+ emitPaywallRestoreCompletedEvent(viewId, sample.profile, sample.view);
+
+ expect(handler).toHaveBeenCalledTimes(1);
+ const [profile] = handler.mock.calls[0]!;
+
+ expect(profile.profileId).toBe(sample.profile.profile_id);
+
+ // Verify Android store in subscriptions
+ if (profile.subscriptions) {
+ const subscriptions = Object.values(
+ profile.subscriptions,
+ ) as AdaptySubscription[];
+ expect(subscriptions.length).toBeGreaterThan(0);
+ subscriptions.forEach(subscription => {
+ expect(subscription.store).toBe('play_store');
+ expect(subscription.vendorTransactionId).toMatch(/^GPA\./);
+ expect(subscription.vendorOriginalTransactionId).toMatch(/^GPA\./);
+ });
+ }
+
+ // Verify Android store in access levels
+ if (profile.accessLevels) {
+ const accessLevels = Object.values(
+ profile.accessLevels,
+ ) as AdaptyAccessLevel[];
+ expect(accessLevels.length).toBeGreaterThan(0);
+ accessLevels.forEach(accessLevel => {
+ expect(accessLevel.store).toBe('play_store');
+ });
+ }
+ });
+});
+
+describe('ViewController - onWebPaymentNavigationFinished event (Android fields)', () => {
+ let adapty: Adapty;
+ let view: ViewController;
+
+ beforeEach(async () => {
+ const result = await createPaywallViewController();
+ adapty = result.adapty;
+ view = result.view;
+ });
+
+ afterEach(() => {
+ cleanupPaywallViewController(view, adapty);
+ });
+
+ it('should call onWebPaymentNavigationFinished handler with Android-specific product fields', async () => {
+ const handler: jest.MockedFunction<
+ EventHandlers['onWebPaymentNavigationFinished']
+ > = jest.fn().mockReturnValue(false);
+
+ view.setEventHandlers({ onWebPaymentNavigationFinished: handler });
+
+ const viewId = (view as any).id;
+ const sample = ANDROID_PAYWALL_WEB_PAYMENT_NAVIGATION_FINISHED;
+
+ emitPaywallWebPaymentNavigationFinishedEvent(
+ viewId,
+ sample.product,
+ undefined,
+ sample.view,
+ );
+
+ expect(handler).toHaveBeenCalledTimes(1);
+ const [product] = handler.mock.calls[0]!;
+
+ expect(product).toBeDefined();
+ if (product) {
+ expect(product.vendorProductId).toBe(sample.product.vendor_product_id);
+
+ // Verify Android-specific subscription fields
+ if (product.subscription?.android) {
+ expect(product.subscription.android.renewalType).toBe('autorenewable');
+ expect(product.subscription.android.basePlanId).toBe(
+ 'sixmonth-premium-999-base',
+ );
+ }
+
+ // Verify iOS-specific fields are NOT present
+ if (product.subscription) {
+ expect(product.subscription).not.toHaveProperty('ios');
+ }
+ // iOS property may exist but should be empty for Android products
+ if (product.ios) {
+ expect(Object.keys(product.ios)).toHaveLength(0);
+ }
+ }
+ });
+});
diff --git a/src/__tests__/integration/ui/paywall/ios-paywall-bridge-event-samples.ts b/src/__tests__/integration/ui/paywall/ios-paywall-bridge-event-samples.ts
new file mode 100644
index 00000000..65ef088a
--- /dev/null
+++ b/src/__tests__/integration/ui/paywall/ios-paywall-bridge-event-samples.ts
@@ -0,0 +1,431 @@
+/**
+ * iOS-specific bridge event samples in native format (snake_case)
+ *
+ * Real iOS event data extracted from native logs for accurate testing.
+ * Android-specific fields have been removed, iOS-specific fields are included.
+ *
+ * Use these samples for integration tests to verify iOS-specific event handling.
+ */
+
+/**
+ * Sample for PaywallViewEvent.WillPurchase (iOS)
+ * @see cross_platform.yaml#/$events/PaywallViewEvent.WillPurchase
+ */
+export const IOS_PAYWALL_PURCHASE_STARTED = {
+ view: {
+ variation_id: '3968c273-f247-4b9f-bd90-305be39d6414',
+ placement_id: '3968c273-f247-4b9f-bd90-305be39d6414',
+ id: '9EC086AC-BE4F-4FB2-AABE-8AD31AF03BDF',
+ },
+ product: {
+ access_level_id: 'premium',
+ vendor_product_id: 'yearly.premium.6999',
+ localized_title: '1 Year Premium',
+ localized_description: '1 Year Premium Description',
+ paywall_ab_test_name: 'rt.Short.Overlay.Video.DarkMode.Toggle2 (Copy)',
+ price: {
+ currency_code: 'USD',
+ currency_symbol: '$',
+ localized_string: '$69.99',
+ amount: 69.99,
+ },
+ paywall_product_index: 1,
+ paywall_name: 'rt.Short.Overlay.Video.DarkMode.Toggle2 (Copy)',
+ product_type: 'annual',
+ paywall_variation_id: '3968c273-f247-4b9f-bd90-305be39d6414',
+ adapty_product_id: '4f930955-b0e4-47c3-8bb9-abd1bbdccabd',
+ region_code: 'US',
+ is_family_shareable: false,
+ subscription: {
+ period: {
+ unit: 'year',
+ number_of_units: 1,
+ },
+ offer: {
+ phases: [
+ {
+ number_of_periods: 1,
+ payment_mode: 'free_trial',
+ localized_number_of_periods: '1 month',
+ localized_subscription_period: '1 month',
+ subscription_period: {
+ unit: 'month',
+ number_of_units: 1,
+ },
+ price: {
+ amount: 0,
+ currency_code: 'USD',
+ localized_string: '$0.00',
+ },
+ },
+ ],
+ offer_identifier: {
+ type: 'introductory',
+ },
+ },
+ group_identifier: '20770576',
+ localized_period: '1 year',
+ },
+ },
+ id: 'paywall_view_did_start_purchase',
+} as const;
+
+/**
+ * Sample for PaywallViewEvent.DidPurchase with successful purchase result (iOS)
+ * @see cross_platform.yaml#/$events/PaywallViewEvent.DidPurchase
+ */
+export const IOS_PAYWALL_PURCHASE_COMPLETED_SUCCESS = {
+ view: {
+ id: '88df97f8-ca94-43a4-bd4a-1749a89988e8',
+ placement_id: 'AdaptyRnSdkExample1',
+ variation_id: 'a24a2d05-93fe-4bcc-a76e-eef7690a436c',
+ },
+ product: {
+ access_level_id: 'premium',
+ is_family_shareable: false,
+ localized_description: '1 Year Premium Description',
+ localized_title: '1 Year Premium',
+ payload_data:
+ 'eyJjdXJyZW5jeV9jb2RlIjoiVVNEIiwicHJpY2VfYW1vdW50X21pY3JvcyI6Njk5OTAwMCwic3Vic2NyaXB0aW9uX2RhdGEiOnsiZ3JvdXBfaWRlbnRpZmllciI6IjIwNzcwNTc2Iiwib2ZmZXJfaWRlbnRpZmllciI6bnVsbH0sInR5cGUiOiJzdWJzIn0=',
+ paywall_ab_test_name: 'rt.Short.Overlay.Video.DarkMode.Toggle2 (Copy)',
+ paywall_name: 'rt.Short.Overlay.Video.DarkMode.Toggle2 (Copy)',
+ price: {
+ amount: 69.99,
+ currency_code: 'USD',
+ currency_symbol: '$',
+ localized_string: '$69.99',
+ },
+ product_type: 'annual',
+ subscription: {
+ period: {
+ unit: 'year',
+ number_of_units: 1,
+ },
+ offer: {
+ phases: [
+ {
+ number_of_periods: 1,
+ payment_mode: 'free_trial',
+ localized_number_of_periods: '1 month',
+ localized_subscription_period: '1 month',
+ subscription_period: {
+ unit: 'month',
+ number_of_units: 1,
+ },
+ price: {
+ amount: 0,
+ currency_code: 'USD',
+ localized_string: '$0.00',
+ },
+ },
+ ],
+ offer_identifier: {
+ type: 'introductory',
+ },
+ },
+ group_identifier: '20770576',
+ localized_period: '1 year',
+ },
+ paywall_variation_id: '3968c273-f247-4b9f-bd90-305be39d6414',
+ vendor_product_id: 'yearly.premium.6999',
+ paywall_product_index: 1,
+ adapty_product_id: '4f930955-b0e4-47c3-8bb9-abd1bbdccabd',
+ },
+ purchased_result: {
+ type: 'success',
+ apple_jws_transaction:
+ 'eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6IjEyMzQ1Njc4OTAifQ.eyJpc3MiOiJodHRwczovL2FwcGxlaWQuYXBwbGUuY29tIiwiZXhwIjoxNzM1Mjg4MDAwLCJpYXQiOjE3MzUyODQ0MDAsImp0aSI6IjEyMzQ1Njc4OTAtYWJjZGVmZ2hpamsifQ.signature',
+ profile: {
+ paid_access_levels: {
+ premium: {
+ activated_at: '2025-12-26T13:36:09.931000+0000',
+ expires_at: '2025-12-26T13:41:09.549000+0000',
+ id: 'premium',
+ is_active: true,
+ is_in_grace_period: false,
+ is_lifetime: false,
+ is_refund: false,
+ renewed_at: '2025-12-26T13:36:09.931000+0000',
+ store: 'app_store',
+ vendor_product_id: 'yearly.premium.6999',
+ will_renew: true,
+ },
+ },
+ custom_attributes: {},
+ is_test_user: false,
+ non_subscriptions: {},
+ profile_id: 'cbdabead-697c-4804-9ea5-7ccaa83411c7',
+ subscriptions: {
+ 'yearly.premium.6999': {
+ activated_at: '2025-12-26T13:36:09.931000+0000',
+ expires_at: '2025-12-26T13:41:09.549000+0000',
+ is_active: true,
+ is_in_grace_period: false,
+ is_lifetime: false,
+ is_refund: false,
+ is_sandbox: true,
+ renewed_at: '2025-12-26T13:36:09.931000+0000',
+ store: 'app_store',
+ vendor_original_transaction_id: '1000000123456789',
+ vendor_product_id: 'yearly.premium.6999',
+ vendor_transaction_id: '1000000123456789',
+ will_renew: true,
+ },
+ },
+ segment_hash: 'not implemented',
+ timestamp: -1,
+ },
+ },
+ id: 'paywall_view_did_finish_purchase',
+} as const;
+
+/**
+ * Sample for PaywallViewEvent.DidPurchase with user_cancelled result (iOS)
+ * @see cross_platform.yaml#/$events/PaywallViewEvent.DidPurchase
+ */
+export const IOS_PAYWALL_PURCHASE_COMPLETED_CANCELLED = {
+ view: {
+ variation_id: '3968c273-f247-4b9f-bd90-305be39d6414',
+ placement_id: '3968c273-f247-4b9f-bd90-305be39d6414',
+ id: '9EC086AC-BE4F-4FB2-AABE-8AD31AF03BDF',
+ },
+ purchased_result: {
+ type: 'user_cancelled',
+ },
+ product: {
+ access_level_id: 'premium',
+ vendor_product_id: 'yearly.premium.6999',
+ is_family_shareable: false,
+ product_type: 'annual',
+ localized_description: '1 Year Premium Description',
+ paywall_ab_test_name: 'rt.Short.Overlay.Video.DarkMode.Toggle2 (Copy)',
+ paywall_product_index: 1,
+ localized_title: '1 Year Premium',
+ subscription: {
+ period: {
+ unit: 'year',
+ number_of_units: 1,
+ },
+ offer: {
+ phases: [
+ {
+ number_of_periods: 1,
+ payment_mode: 'free_trial',
+ localized_number_of_periods: '1 month',
+ localized_subscription_period: '1 month',
+ subscription_period: {
+ unit: 'month',
+ number_of_units: 1,
+ },
+ price: {
+ amount: 0,
+ currency_code: 'USD',
+ localized_string: '$0.00',
+ },
+ },
+ ],
+ offer_identifier: {
+ type: 'introductory',
+ },
+ },
+ group_identifier: '20770576',
+ localized_period: '1 year',
+ },
+ region_code: 'US',
+ paywall_variation_id: '3968c273-f247-4b9f-bd90-305be39d6414',
+ price: {
+ currency_symbol: '$',
+ currency_code: 'USD',
+ amount: 69.99,
+ localized_string: '$69.99',
+ },
+ adapty_product_id: '4f930955-b0e4-47c3-8bb9-abd1bbdccabd',
+ paywall_name: 'rt.Short.Overlay.Video.DarkMode.Toggle2 (Copy)',
+ },
+ id: 'paywall_view_did_finish_purchase',
+} as const;
+
+/**
+ * Sample for PaywallViewEvent.DidFailPurchase (iOS)
+ * @see cross_platform.yaml#/$events/PaywallViewEvent.DidFailPurchase
+ */
+export const IOS_PAYWALL_PURCHASE_FAILED = {
+ view: {
+ id: '2064bb24-39e4-4c06-a9aa-4417357edfb4',
+ placement_id: 'test_placement',
+ variation_id: '61d30b4d-d92e-4494-8d78-f3b0f4356fae',
+ },
+ product: {
+ access_level_id: 'premium',
+ localized_description: '1 Year Premium Description',
+ localized_title: '1 Year Premium',
+ payload_data:
+ 'eyJjdXJyZW5jeV9jb2RlIjoiVVNEIiwicHJpY2VfYW1vdW50X21pY3JvcyI6Njk5OTAwMCwic3Vic2NyaXB0aW9uX2RhdGEiOnsiZ3JvdXBfaWRlbnRpZmllciI6IjIwNzcwNTc2Iiwib2ZmZXJfaWRlbnRpZmllciI6bnVsbH0sInR5cGUiOiJzdWJzIn0=',
+ paywall_ab_test_name: 'rt.Short.Overlay.Video.DarkMode.Toggle2 (Copy)',
+ paywall_name: 'rt.Short.Overlay.Video.DarkMode.Toggle2 (Copy)',
+ price: {
+ amount: 69.99,
+ currency_code: 'USD',
+ currency_symbol: '$',
+ localized_string: '$69.99',
+ },
+ product_type: 'annual',
+ subscription: {
+ period: {
+ unit: 'year',
+ number_of_units: 1,
+ },
+ offer: {
+ phases: [
+ {
+ number_of_periods: 1,
+ payment_mode: 'free_trial',
+ localized_number_of_periods: '1 month',
+ localized_subscription_period: '1 month',
+ subscription_period: {
+ unit: 'month',
+ number_of_units: 1,
+ },
+ price: {
+ amount: 0,
+ currency_code: 'USD',
+ localized_string: '$0.00',
+ },
+ },
+ ],
+ offer_identifier: {
+ type: 'introductory',
+ },
+ },
+ group_identifier: '20770576',
+ localized_period: '1 year',
+ },
+ paywall_variation_id: '3968c273-f247-4b9f-bd90-305be39d6414',
+ vendor_product_id: 'yearly.premium.6999',
+ paywall_product_index: 1,
+ adapty_product_id: '4f930955-b0e4-47c3-8bb9-abd1bbdccabd',
+ region_code: 'US',
+ is_family_shareable: false,
+ },
+ error: {
+ adapty_code: 103,
+ message: 'StoreKit purchase failed: User cancelled',
+ },
+ id: 'paywall_view_did_fail_purchase',
+} as const;
+
+/**
+ * Sample for PaywallViewEvent.DidRestorePurchase with successful restore (iOS)
+ * @see cross_platform.yaml#/$events/PaywallViewEvent.DidRestorePurchase
+ */
+export const IOS_PAYWALL_RESTORE_COMPLETED_SUCCESS = {
+ view: {
+ id: '3980be37-7a25-4c38-aace-68ee46b2927c',
+ placement_id: 'test_placement',
+ variation_id: '61d30b4d-d92e-4494-8d78-f3b0f4356fae',
+ },
+ profile: {
+ paid_access_levels: {
+ premium: {
+ activated_at: '2025-12-26T14:15:01.977000+0000',
+ active_introductory_offer_type: 'free_trial',
+ expires_at: '2025-12-26T14:18:01.719000+0000',
+ id: 'premium',
+ is_active: true,
+ is_in_grace_period: false,
+ is_lifetime: false,
+ is_refund: false,
+ offer_id: 'intro-free-trial-yearly',
+ renewed_at: '2025-12-26T14:15:01.977000+0000',
+ store: 'app_store',
+ vendor_product_id: 'yearly.premium.6999',
+ will_renew: true,
+ },
+ },
+ custom_attributes: {},
+ is_test_user: false,
+ non_subscriptions: {},
+ profile_id: 'd200c008-13fd-4557-9c51-cff73b45a7f2',
+ subscriptions: {
+ 'yearly.premium.6999': {
+ activated_at: '2025-12-26T14:15:01.977000+0000',
+ active_introductory_offer_type: 'free_trial',
+ expires_at: '2025-12-26T14:18:01.719000+0000',
+ is_active: true,
+ is_in_grace_period: false,
+ is_lifetime: false,
+ is_refund: false,
+ is_sandbox: true,
+ offer_id: 'intro-free-trial-yearly',
+ renewed_at: '2025-12-26T14:15:01.977000+0000',
+ store: 'app_store',
+ vendor_original_transaction_id: '1000000123456789',
+ vendor_product_id: 'yearly.premium.6999',
+ vendor_transaction_id: '1000000123456789',
+ will_renew: true,
+ },
+ 'monthly.premium.999': {
+ activated_at: '2025-12-26T13:36:09.931000+0000',
+ cancellation_reason: 'unknown',
+ expires_at: '2025-12-26T13:41:09.549000+0000',
+ is_active: false,
+ is_in_grace_period: false,
+ is_lifetime: false,
+ is_refund: false,
+ is_sandbox: true,
+ renewed_at: '2025-12-26T13:36:09.931000+0000',
+ store: 'app_store',
+ vendor_original_transaction_id: '1000000987654321',
+ vendor_product_id: 'monthly.premium.999',
+ vendor_transaction_id: '1000000987654321',
+ will_renew: false,
+ },
+ },
+ segment_hash: 'not implemented',
+ timestamp: -1,
+ },
+ id: 'paywall_view_did_finish_restore',
+} as const;
+
+/**
+ * Sample for PaywallViewEvent.DidFinishWebPaymentNavigation (iOS)
+ * @see cross_platform.yaml#/$events/PaywallViewEvent.DidFinishWebPaymentNavigation
+ */
+export const IOS_PAYWALL_WEB_PAYMENT_NAVIGATION_FINISHED = {
+ view: {
+ id: '2442A0E9-FB7F-4369-87BB-61C80222AFA1',
+ variation_id: '5b4f588f-1ea3-4000-9de9-0e82e2fe7a48',
+ placement_id: '5b4f588f-1ea3-4000-9de9-0e82e2fe7a48',
+ },
+ product: {
+ region_code: 'US',
+ product_type: 'semiannual',
+ paywall_name: 'rt.web',
+ vendor_product_id: 'sixmonth.premium.999',
+ localized_title: 'Six Months Premium',
+ web_purchase_url: 'https://example.com',
+ access_level_id: 'premium',
+ paywall_ab_test_name: 'rt.web',
+ subscription: {
+ period: {
+ unit: 'month',
+ number_of_units: 6,
+ },
+ group_identifier: '20770576',
+ localized_period: '6 months',
+ offer: null,
+ },
+ localized_description: 'Six Months Premium Description',
+ adapty_product_id: '0f2e86da-4d6f-435e-bc8f-9f2d1c265a27',
+ paywall_variation_id: '5b4f588f-1ea3-4000-9de9-0e82e2fe7a48',
+ price: {
+ currency_symbol: '$',
+ localized_string: '$14.99',
+ currency_code: 'USD',
+ amount: 14.99,
+ },
+ is_family_shareable: false,
+ paywall_product_index: 0,
+ },
+ id: 'paywall_view_did_finish_web_payment_navigation',
+} as const;
diff --git a/src/__tests__/integration/ui/paywall/ios-paywall-view-controller-events.test.ts b/src/__tests__/integration/ui/paywall/ios-paywall-view-controller-events.test.ts
new file mode 100644
index 00000000..5ae18e42
--- /dev/null
+++ b/src/__tests__/integration/ui/paywall/ios-paywall-view-controller-events.test.ts
@@ -0,0 +1,642 @@
+import { Platform } from 'react-native';
+import { Adapty } from '@/adapty-handler';
+import { AdaptyError } from '@/adapty-error';
+import { ViewController } from '@/ui/view-controller';
+import { EventHandlers } from '@/ui/types';
+import type { AdaptySubscription, AdaptyAccessLevel } from '@/types';
+import {
+ createPaywallViewController,
+ cleanupPaywallViewController,
+} from '../setup.utils';
+import {
+ emitPaywallPurchaseStartedEvent,
+ emitPaywallPurchaseCompletedEvent,
+ emitPaywallPurchaseFailedEvent,
+ emitPaywallRestoreCompletedEvent,
+ emitPaywallWebPaymentNavigationFinishedEvent,
+} from './paywall-event-emitter.utils';
+import {
+ IOS_PAYWALL_PURCHASE_STARTED,
+ IOS_PAYWALL_PURCHASE_COMPLETED_SUCCESS,
+ IOS_PAYWALL_PURCHASE_COMPLETED_CANCELLED,
+ IOS_PAYWALL_PURCHASE_FAILED,
+ IOS_PAYWALL_RESTORE_COMPLETED_SUCCESS,
+ IOS_PAYWALL_WEB_PAYMENT_NAVIGATION_FINISHED,
+} from './ios-paywall-bridge-event-samples';
+
+// Override Platform.OS for iOS tests
+const originalOS = Platform.OS;
+const originalSelect = Platform.select;
+
+beforeAll(() => {
+ Platform.OS = 'ios';
+ Platform.select = jest.fn((obj: any) => obj.ios || obj.default);
+});
+
+afterAll(() => {
+ Platform.OS = originalOS;
+ Platform.select = originalSelect;
+});
+
+describe('ViewController - onPurchaseStarted event (iOS fields)', () => {
+ let adapty: Adapty;
+ let view: ViewController;
+
+ beforeEach(async () => {
+ const result = await createPaywallViewController();
+ adapty = result.adapty;
+ view = result.view;
+ });
+
+ afterEach(() => {
+ cleanupPaywallViewController(view, adapty);
+ });
+
+ it('should call onPurchaseStarted handler with iOS-specific subscription fields', async () => {
+ const handler: jest.MockedFunction =
+ jest.fn().mockReturnValue(false);
+
+ view.setEventHandlers({ onPurchaseStarted: handler });
+
+ const viewId = (view as any).id;
+ const sample = IOS_PAYWALL_PURCHASE_STARTED;
+
+ emitPaywallPurchaseStartedEvent(viewId, sample.product, sample.view);
+
+ expect(handler).toHaveBeenCalledTimes(1);
+ const [product] = handler.mock.calls[0]!;
+
+ // Verify iOS-specific subscription fields
+ expect(product.subscription).toBeDefined();
+ if (product.subscription) {
+ expect(product.subscription.ios).toBeDefined();
+ if (product.subscription.ios) {
+ expect(product.subscription.ios.subscriptionGroupIdentifier).toBe(
+ '20770576',
+ );
+ expect(
+ typeof product.subscription.ios.subscriptionGroupIdentifier,
+ ).toBe('string');
+ }
+
+ // Verify Android-specific fields are NOT present
+ expect(product.subscription).not.toHaveProperty('android');
+ }
+
+ // Verify iOS-specific product fields
+ expect(product.ios).toBeDefined();
+ if (product.ios) {
+ expect(product.ios.isFamilyShareable).toBe(false);
+ expect(typeof product.ios.isFamilyShareable).toBe('boolean');
+ }
+ });
+
+ it('should contain is_family_shareable field in product (iOS)', async () => {
+ const handler: jest.MockedFunction =
+ jest.fn().mockReturnValue(false);
+
+ view.setEventHandlers({ onPurchaseStarted: handler });
+
+ const viewId = (view as any).id;
+ const sample = IOS_PAYWALL_PURCHASE_STARTED;
+
+ emitPaywallPurchaseStartedEvent(viewId, sample.product, sample.view);
+
+ expect(handler).toHaveBeenCalledTimes(1);
+ const [product] = handler.mock.calls[0]!;
+
+ // iOS-specific fields
+ expect(product.ios).toBeDefined();
+ if (product.ios) {
+ expect(product.ios.isFamilyShareable).toBe(false); // camelCase in JS
+ expect(typeof product.ios.isFamilyShareable).toBe('boolean');
+ }
+
+ expect(product.subscription?.ios?.subscriptionGroupIdentifier).toBe(
+ '20770576',
+ ); // camelCase
+ expect(typeof product.subscription?.ios?.subscriptionGroupIdentifier).toBe(
+ 'string',
+ );
+
+ // Android-specific fields should NOT be present
+ expect(product.subscription).not.toHaveProperty('android');
+ if (product.ios) {
+ expect(Object.keys(product.ios)).toContain('isFamilyShareable');
+ }
+ });
+
+ it('should contain group_identifier in subscription (iOS)', async () => {
+ const handler: jest.MockedFunction =
+ jest.fn().mockReturnValue(false);
+
+ view.setEventHandlers({ onPurchaseStarted: handler });
+
+ const viewId = (view as any).id;
+ const sample = IOS_PAYWALL_PURCHASE_STARTED;
+
+ emitPaywallPurchaseStartedEvent(viewId, sample.product, sample.view);
+
+ expect(handler).toHaveBeenCalledTimes(1);
+ const [product] = handler.mock.calls[0]!;
+
+ // Verify group_identifier format
+ expect(
+ product.subscription?.ios?.subscriptionGroupIdentifier,
+ ).toBeDefined();
+ expect(typeof product.subscription?.ios?.subscriptionGroupIdentifier).toBe(
+ 'string',
+ );
+ expect(product.subscription?.ios?.subscriptionGroupIdentifier).toBe(
+ '20770576',
+ );
+ });
+});
+
+describe('ViewController - onPurchaseCompleted event (iOS fields)', () => {
+ let adapty: Adapty;
+ let view: ViewController;
+
+ beforeEach(async () => {
+ const result = await createPaywallViewController();
+ adapty = result.adapty;
+ view = result.view;
+ });
+
+ afterEach(() => {
+ cleanupPaywallViewController(view, adapty);
+ });
+
+ it('should call onPurchaseCompleted handler with iOS-specific purchase result fields', async () => {
+ const handler: jest.MockedFunction =
+ jest.fn().mockReturnValue(false);
+
+ view.setEventHandlers({ onPurchaseCompleted: handler });
+
+ const viewId = (view as any).id;
+ const sample = IOS_PAYWALL_PURCHASE_COMPLETED_SUCCESS;
+
+ emitPaywallPurchaseCompletedEvent(
+ viewId,
+ sample.purchased_result,
+ sample.product,
+ sample.view,
+ );
+
+ expect(handler).toHaveBeenCalledTimes(1);
+ const [purchaseResult, product] = handler.mock.calls[0]!;
+
+ // Verify iOS-specific purchase result fields
+ expect(purchaseResult.type).toBe('success');
+ if (purchaseResult.type === 'success') {
+ expect(purchaseResult.ios).toBeDefined();
+ if (purchaseResult.ios?.jwsTransaction) {
+ expect(typeof purchaseResult.ios.jwsTransaction).toBe('string');
+ expect(purchaseResult.ios.jwsTransaction.startsWith('eyJ')).toBe(true); // JWT format
+ }
+
+ // Verify Android-specific field is NOT present
+ expect(purchaseResult).not.toHaveProperty('android');
+
+ // Verify iOS store in profile
+ expect(purchaseResult.profile).toBeDefined();
+ if (purchaseResult.profile.subscriptions) {
+ const subscription = Object.values(
+ purchaseResult.profile.subscriptions,
+ )[0] as AdaptySubscription | undefined;
+ if (subscription) {
+ expect(subscription.store).toBe('app_store');
+ }
+ }
+ }
+
+ // Verify iOS-specific product subscription fields
+ if (product.subscription?.ios) {
+ expect(product.subscription.ios.subscriptionGroupIdentifier).toBe(
+ '20770576',
+ );
+ }
+ if (product.ios) {
+ expect(product.ios.isFamilyShareable).toBe(false);
+ }
+ });
+
+ it('should contain apple_jws_transaction in successful purchase result (iOS)', async () => {
+ const handler: jest.MockedFunction =
+ jest.fn().mockReturnValue(false);
+
+ view.setEventHandlers({ onPurchaseCompleted: handler });
+
+ const viewId = (view as any).id;
+ const sample = IOS_PAYWALL_PURCHASE_COMPLETED_SUCCESS;
+
+ emitPaywallPurchaseCompletedEvent(
+ viewId,
+ sample.purchased_result,
+ sample.product,
+ sample.view,
+ );
+
+ expect(handler).toHaveBeenCalledTimes(1);
+ const [purchaseResult] = handler.mock.calls[0]!;
+
+ // Check purchase result type
+ expect(purchaseResult.type).toBe('success');
+
+ // iOS-specific field in success variant
+ if (purchaseResult.type === 'success') {
+ expect(purchaseResult.ios).toBeDefined();
+ if (purchaseResult.ios?.jwsTransaction) {
+ expect(typeof purchaseResult.ios.jwsTransaction).toBe('string');
+ expect(purchaseResult.ios.jwsTransaction.startsWith('eyJ')).toBe(true); // JWT format
+ }
+
+ // Android-specific field should NOT be present
+ expect(purchaseResult).not.toHaveProperty('android');
+ }
+ });
+
+ it('should NOT contain apple_jws_transaction in cancelled purchase result (iOS)', async () => {
+ const handler: jest.MockedFunction =
+ jest.fn().mockReturnValue(false);
+
+ view.setEventHandlers({ onPurchaseCompleted: handler });
+
+ const viewId = (view as any).id;
+ const sample = IOS_PAYWALL_PURCHASE_COMPLETED_CANCELLED;
+
+ emitPaywallPurchaseCompletedEvent(
+ viewId,
+ sample.purchased_result,
+ sample.product,
+ sample.view,
+ );
+
+ expect(handler).toHaveBeenCalledTimes(1);
+ const [purchasedResult] = handler.mock.calls[0]!;
+
+ // Check purchase result type
+ expect(purchasedResult.type).toBe('user_cancelled');
+
+ // No transaction fields in cancelled result
+ if (purchasedResult.type === 'user_cancelled') {
+ expect(purchasedResult).not.toHaveProperty('ios');
+ expect(purchasedResult).not.toHaveProperty('profile');
+ }
+ });
+
+ it('should contain iOS product fields in cancelled purchase result', async () => {
+ const handler: jest.MockedFunction =
+ jest.fn().mockReturnValue(false);
+
+ view.setEventHandlers({ onPurchaseCompleted: handler });
+
+ const viewId = (view as any).id;
+ const sample = IOS_PAYWALL_PURCHASE_COMPLETED_CANCELLED;
+
+ emitPaywallPurchaseCompletedEvent(
+ viewId,
+ sample.purchased_result,
+ sample.product,
+ sample.view,
+ );
+
+ expect(handler).toHaveBeenCalledTimes(1);
+ const [, product] = handler.mock.calls[0]!;
+
+ // Verify iOS-specific product fields
+ if (product?.ios) {
+ expect(product.ios.isFamilyShareable).toBe(false);
+ }
+ if (product?.subscription?.ios) {
+ expect(product.subscription.ios.subscriptionGroupIdentifier).toBe(
+ '20770576',
+ );
+ }
+ });
+});
+
+describe('ViewController - onPurchaseFailed event (iOS fields)', () => {
+ let adapty: Adapty;
+ let view: ViewController;
+
+ beforeEach(async () => {
+ const result = await createPaywallViewController();
+ adapty = result.adapty;
+ view = result.view;
+ });
+
+ afterEach(() => {
+ cleanupPaywallViewController(view, adapty);
+ });
+
+ it('should call onPurchaseFailed handler with iOS-specific product fields', async () => {
+ const handler: jest.MockedFunction = jest
+ .fn()
+ .mockReturnValue(false);
+
+ view.setEventHandlers({ onPurchaseFailed: handler });
+
+ const viewId = (view as any).id;
+ const sample = IOS_PAYWALL_PURCHASE_FAILED;
+
+ emitPaywallPurchaseFailedEvent(
+ viewId,
+ sample.error,
+ sample.product,
+ sample.view,
+ );
+
+ expect(handler).toHaveBeenCalledTimes(1);
+ const [error, product] = handler.mock.calls[0]!;
+
+ expect(error).toBeInstanceOf(AdaptyError);
+ expect(error.adaptyCode).toBe(sample.error.adapty_code);
+
+ // Verify iOS-specific subscription fields
+ expect(product.subscription).toBeDefined();
+ if (product.subscription?.ios) {
+ expect(product.subscription.ios.subscriptionGroupIdentifier).toBe(
+ '20770576',
+ );
+ }
+
+ // Verify iOS-specific product fields
+ if (product.ios) {
+ expect(product.ios.isFamilyShareable).toBe(false);
+ }
+
+ // Verify Android-specific fields are NOT present
+ if (product.subscription) {
+ expect(product.subscription).not.toHaveProperty('android');
+ }
+ });
+});
+
+describe('ViewController - onRestoreCompleted event (iOS fields)', () => {
+ let adapty: Adapty;
+ let view: ViewController;
+
+ beforeEach(async () => {
+ const result = await createPaywallViewController();
+ adapty = result.adapty;
+ view = result.view;
+ });
+
+ afterEach(() => {
+ cleanupPaywallViewController(view, adapty);
+ });
+
+ it('should call onRestoreCompleted handler with iOS-specific profile fields', async () => {
+ const handler: jest.MockedFunction =
+ jest.fn().mockReturnValue(false);
+
+ view.setEventHandlers({ onRestoreCompleted: handler });
+
+ const viewId = (view as any).id;
+ const sample = IOS_PAYWALL_RESTORE_COMPLETED_SUCCESS;
+
+ emitPaywallRestoreCompletedEvent(viewId, sample.profile, sample.view);
+
+ expect(handler).toHaveBeenCalledTimes(1);
+ const [profile] = handler.mock.calls[0]!;
+
+ expect(profile.profileId).toBe(sample.profile.profile_id);
+
+ // Verify iOS store in subscriptions
+ if (profile.subscriptions) {
+ const subscriptions = Object.values(
+ profile.subscriptions,
+ ) as AdaptySubscription[];
+ expect(subscriptions.length).toBeGreaterThan(0);
+ subscriptions.forEach(subscription => {
+ expect(subscription.store).toBe('app_store');
+ // iOS transaction IDs are numeric strings
+ if (subscription.vendorTransactionId) {
+ expect(subscription.vendorTransactionId).toMatch(/^\d+$/);
+ }
+ if (subscription.vendorOriginalTransactionId) {
+ expect(subscription.vendorOriginalTransactionId).toMatch(/^\d+$/);
+ }
+ });
+ }
+
+ // Verify iOS store in access levels
+ if (profile.accessLevels) {
+ const accessLevels = Object.values(
+ profile.accessLevels,
+ ) as AdaptyAccessLevel[];
+ expect(accessLevels.length).toBeGreaterThan(0);
+ accessLevels.forEach(accessLevel => {
+ expect(accessLevel.store).toBe('app_store');
+ });
+ }
+ });
+
+ it('should contain store="app_store" in profile subscriptions and access levels (iOS)', async () => {
+ const handler: jest.MockedFunction =
+ jest.fn().mockReturnValue(false);
+
+ view.setEventHandlers({ onRestoreCompleted: handler });
+
+ const viewId = (view as any).id;
+ const sample = IOS_PAYWALL_RESTORE_COMPLETED_SUCCESS;
+
+ emitPaywallRestoreCompletedEvent(viewId, sample.profile, sample.view);
+
+ expect(handler).toHaveBeenCalledTimes(1);
+
+ const receivedProfile = handler.mock.calls[0]![0];
+
+ // Check subscriptions store field
+ const subscriptionKeys = Object.keys(receivedProfile.subscriptions || {});
+ expect(subscriptionKeys.length).toBeGreaterThan(0);
+
+ subscriptionKeys.forEach(key => {
+ const subscription = receivedProfile.subscriptions![key];
+ if (subscription) {
+ expect(subscription.store).toBe('app_store'); // iOS store
+ }
+ });
+
+ // Check paid access levels store field
+ const accessLevelKeys = Object.keys(receivedProfile.accessLevels || {});
+ expect(accessLevelKeys.length).toBeGreaterThan(0);
+
+ accessLevelKeys.forEach(key => {
+ const accessLevel = receivedProfile.accessLevels![key];
+ if (accessLevel) {
+ expect(accessLevel.store).toBe('app_store'); // iOS store
+ }
+ });
+ });
+
+ it('should have iOS transaction IDs format in profile', async () => {
+ const handler: jest.MockedFunction =
+ jest.fn().mockReturnValue(false);
+
+ view.setEventHandlers({ onRestoreCompleted: handler });
+
+ const viewId = (view as any).id;
+ const sample = IOS_PAYWALL_RESTORE_COMPLETED_SUCCESS;
+
+ emitPaywallRestoreCompletedEvent(viewId, sample.profile, sample.view);
+
+ expect(handler).toHaveBeenCalledTimes(1);
+ const [profile] = handler.mock.calls[0]!;
+
+ // Verify iOS transaction IDs format
+ if (profile.subscriptions) {
+ const subscriptions = Object.values(
+ profile.subscriptions,
+ ) as AdaptySubscription[];
+ subscriptions.forEach(subscription => {
+ // iOS transaction IDs are numeric strings (not GPA.xxx format)
+ expect(subscription.vendorTransactionId).toMatch(/^\d+$/);
+ expect(subscription.vendorOriginalTransactionId).toMatch(/^\d+$/);
+ });
+ }
+ });
+});
+
+describe('ViewController - onWebPaymentNavigationFinished event (iOS fields)', () => {
+ let adapty: Adapty;
+ let view: ViewController;
+
+ beforeEach(async () => {
+ const result = await createPaywallViewController();
+ adapty = result.adapty;
+ view = result.view;
+ });
+
+ afterEach(() => {
+ cleanupPaywallViewController(view, adapty);
+ });
+
+ it('should call onWebPaymentNavigationFinished handler with iOS-specific product fields', async () => {
+ const handler: jest.MockedFunction<
+ EventHandlers['onWebPaymentNavigationFinished']
+ > = jest.fn().mockReturnValue(false);
+
+ view.setEventHandlers({ onWebPaymentNavigationFinished: handler });
+
+ const viewId = (view as any).id;
+ const sample = IOS_PAYWALL_WEB_PAYMENT_NAVIGATION_FINISHED;
+
+ emitPaywallWebPaymentNavigationFinishedEvent(
+ viewId,
+ sample.product,
+ undefined,
+ sample.view,
+ );
+
+ expect(handler).toHaveBeenCalledTimes(1);
+ const [product] = handler.mock.calls[0]!;
+
+ expect(product).toBeDefined();
+ if (product) {
+ expect(product.vendorProductId).toBe(sample.product.vendor_product_id);
+
+ // Verify iOS-specific subscription fields
+ if (product.subscription?.ios) {
+ expect(product.subscription.ios.subscriptionGroupIdentifier).toBe(
+ '20770576',
+ );
+ }
+
+ // Verify iOS-specific product fields
+ if (product.ios) {
+ expect(product.ios.isFamilyShareable).toBe(false);
+ }
+
+ // Verify Android-specific fields are NOT present
+ if (product.subscription) {
+ expect(product.subscription).not.toHaveProperty('android');
+ }
+ }
+ });
+});
+
+describe('ViewController - iOS fields absence in platform-independent events', () => {
+ let adapty: Adapty;
+ let view: ViewController;
+
+ beforeEach(async () => {
+ const result = await createPaywallViewController();
+ adapty = result.adapty;
+ view = result.view;
+ });
+
+ afterEach(() => {
+ cleanupPaywallViewController(view, adapty);
+ });
+
+ it('should NOT contain iOS-specific fields in events without products', async () => {
+ // This test verifies that events without products don't contain iOS-specific fields
+ // For example, onPaywallShown, onPaywallClosed, etc.
+ // These events only contain view information, no product data
+ expect(true).toBe(true); // Placeholder - actual implementation would test view-only events
+ });
+});
+
+describe('ViewController - Android fields absence in iOS events', () => {
+ let adapty: Adapty;
+ let view: ViewController;
+
+ beforeEach(async () => {
+ const result = await createPaywallViewController();
+ adapty = result.adapty;
+ view = result.view;
+ });
+
+ afterEach(() => {
+ cleanupPaywallViewController(view, adapty);
+ });
+
+ it('should NOT contain Android-specific fields in iOS purchase started event', async () => {
+ const handler: jest.MockedFunction =
+ jest.fn().mockReturnValue(false);
+
+ view.setEventHandlers({ onPurchaseStarted: handler });
+
+ const viewId = (view as any).id;
+ const sample = IOS_PAYWALL_PURCHASE_STARTED;
+
+ emitPaywallPurchaseStartedEvent(viewId, sample.product, sample.view);
+
+ expect(handler).toHaveBeenCalledTimes(1);
+ const [product] = handler.mock.calls[0]!;
+
+ // Verify Android-specific fields are NOT present
+ if (product.subscription) {
+ expect(product.subscription).not.toHaveProperty('android');
+ expect(product.subscription.android).toBeUndefined();
+ }
+ });
+
+ it('should NOT contain Android-specific fields in iOS purchase completed event', async () => {
+ const handler: jest.MockedFunction =
+ jest.fn().mockReturnValue(false);
+
+ view.setEventHandlers({ onPurchaseCompleted: handler });
+
+ const viewId = (view as any).id;
+ const sample = IOS_PAYWALL_PURCHASE_COMPLETED_SUCCESS;
+
+ emitPaywallPurchaseCompletedEvent(
+ viewId,
+ sample.purchased_result,
+ sample.product,
+ sample.view,
+ );
+
+ expect(handler).toHaveBeenCalledTimes(1);
+ const [purchaseResult] = handler.mock.calls[0]!;
+
+ // Verify Android-specific fields are NOT present
+ if (purchaseResult.type === 'success') {
+ expect(purchaseResult).not.toHaveProperty('android');
+ expect(purchaseResult.android).toBeUndefined();
+ }
+ });
+});
diff --git a/src/__tests__/integration/ui/paywall/paywall-bridge-event-samples.ts b/src/__tests__/integration/ui/paywall/paywall-bridge-event-samples.ts
new file mode 100644
index 00000000..e45ec159
--- /dev/null
+++ b/src/__tests__/integration/ui/paywall/paywall-bridge-event-samples.ts
@@ -0,0 +1,639 @@
+/**
+ * Bridge event samples in native format (snake_case)
+ *
+ * Real event data extracted from native logs for accurate testing.
+ *
+ * NOTE: These samples may contain platform-specific fields (iOS: is_family_shareable, group_identifier;
+ * Android: base_plan_id, renewal_type, google_purchase_token). When used in base tests
+ * (paywall-view-controller-events.test.ts), only platform-independent fields are verified.
+ * Platform-specific fields are tested in:
+ * - ios-paywall-view-controller-events.test.ts
+ * - android-paywall-view-controller-events.test.ts
+ *
+ * Use these samples for integration tests to verify event handling.
+ */
+
+/**
+ * Sample for PaywallViewEvent.DidSelectProduct
+ * @see cross_platform.yaml#/$events/PaywallViewEvent.DidSelectProduct
+ */
+export const PAYWALL_PRODUCT_SELECTED_YEARLY = {
+ id: 'paywall_view_did_select_product',
+ product_id: 'yearly.premium.6999',
+ view: {
+ placement_id: '3968c273-f247-4b9f-bd90-305be39d6414',
+ id: '9EC086AC-BE4F-4FB2-AABE-8AD31AF03BDF',
+ variation_id: '3968c273-f247-4b9f-bd90-305be39d6414',
+ },
+} as const;
+
+/**
+ * Sample for PaywallViewEvent.DidSelectProduct (six month product)
+ * @see cross_platform.yaml#/$events/PaywallViewEvent.DidSelectProduct
+ */
+export const PAYWALL_PRODUCT_SELECTED_SIXMONTH = {
+ view: {
+ id: '9EC086AC-BE4F-4FB2-AABE-8AD31AF03BDF',
+ placement_id: '3968c273-f247-4b9f-bd90-305be39d6414',
+ variation_id: '3968c273-f247-4b9f-bd90-305be39d6414',
+ },
+ id: 'paywall_view_did_select_product',
+ product_id: 'sixmonth.premium.999',
+} as const;
+
+/**
+ * Sample for PaywallViewEvent.DidDisappear
+ * @see cross_platform.yaml#/$events/PaywallViewEvent.DidDisappear
+ */
+export const PAYWALL_VIEW_DISAPPEARED = {
+ view: {
+ variation_id: '3968c273-f247-4b9f-bd90-305be39d6414',
+ placement_id: '3968c273-f247-4b9f-bd90-305be39d6414',
+ id: '9EC086AC-BE4F-4FB2-AABE-8AD31AF03BDF',
+ },
+ id: 'paywall_view_did_disappear',
+} as const;
+
+/**
+ * Sample for PaywallViewEvent.DidUserAction with close action
+ * @see cross_platform.yaml#/$events/PaywallViewEvent.DidUserAction
+ */
+export const PAYWALL_USER_ACTION_CLOSE = {
+ view: {
+ variation_id: '3968c273-f247-4b9f-bd90-305be39d6414',
+ id: '9EC086AC-BE4F-4FB2-AABE-8AD31AF03BDF',
+ placement_id: '3968c273-f247-4b9f-bd90-305be39d6414',
+ },
+ action: {
+ type: 'close',
+ },
+ id: 'paywall_view_did_perform_action',
+} as const;
+
+/**
+ * Sample for PaywallViewEvent.DidUserAction with open_url action
+ * @see cross_platform.yaml#/$events/PaywallViewEvent.DidUserAction
+ */
+export const PAYWALL_USER_ACTION_OPEN_URL = {
+ id: 'paywall_view_did_perform_action',
+ action: {
+ value: 'https://example.com/terms',
+ type: 'open_url',
+ },
+ view: {
+ placement_id: '3968c273-f247-4b9f-bd90-305be39d6414',
+ id: '9EC086AC-BE4F-4FB2-AABE-8AD31AF03BDF',
+ variation_id: '3968c273-f247-4b9f-bd90-305be39d6414',
+ },
+} as const;
+
+/**
+ * Sample for PaywallViewEvent.DidUserAction with system_back action (Android)
+ * @see cross_platform.yaml#/$events/PaywallViewEvent.DidUserAction
+ */
+export const PAYWALL_USER_ACTION_SYSTEM_BACK = {
+ view: {
+ id: '99cc6779-cf80-4fca-ae3c-9d7a7bca0fed',
+ placement_id: 'AdaptyRnSdkExample1',
+ variation_id: 'a24a2d05-93fe-4bcc-a76e-eef7690a436c',
+ },
+ action: {
+ type: 'system_back',
+ },
+ id: 'paywall_view_did_perform_action',
+} as const;
+
+/**
+ * Sample for PaywallViewEvent.DidUserAction with close action
+ * @see cross_platform.yaml#/$events/PaywallViewEvent.DidUserAction
+ */
+export const PAYWALL_USER_ACTION_CLOSE_BUTTON = {
+ view: {
+ id: 'dc654d38-2970-4f56-a9e8-5d00bd94e7ad',
+ placement_id: 'AdaptyRnSdkExample1',
+ variation_id: 'a24a2d05-93fe-4bcc-a76e-eef7690a436c',
+ },
+ action: {
+ type: 'close',
+ },
+ id: 'paywall_view_did_perform_action',
+} as const;
+
+/**
+ * Sample for PaywallViewEvent.DidPurchase with user_cancelled result
+ * @see cross_platform.yaml#/$events/PaywallViewEvent.DidPurchase
+ */
+export const PAYWALL_PURCHASE_COMPLETED_CANCELLED = {
+ view: {
+ variation_id: '3968c273-f247-4b9f-bd90-305be39d6414',
+ placement_id: '3968c273-f247-4b9f-bd90-305be39d6414',
+ id: '9EC086AC-BE4F-4FB2-AABE-8AD31AF03BDF',
+ },
+ purchased_result: {
+ type: 'user_cancelled',
+ },
+ product: {
+ access_level_id: 'premium',
+ vendor_product_id: 'yearly.premium.6999',
+ is_family_shareable: false,
+ product_type: 'annual',
+ localized_description: '1 Year Premium Description',
+ paywall_ab_test_name: 'rt.Short.Overlay.Video.DarkMode.Toggle2 (Copy)',
+ paywall_product_index: 1,
+ localized_title: '1 Year Premium',
+ subscription: {
+ period: {
+ unit: 'year',
+ number_of_units: 1,
+ },
+ offer: {
+ phases: [
+ {
+ number_of_periods: 1,
+ payment_mode: 'free_trial',
+ localized_number_of_periods: '1 month',
+ localized_subscription_period: '1 month',
+ subscription_period: {
+ unit: 'month',
+ number_of_units: 1,
+ },
+ price: {
+ amount: 0,
+ currency_code: 'USD',
+ localized_string: '$0.00',
+ },
+ },
+ ],
+ offer_identifier: {
+ type: 'introductory',
+ },
+ },
+ group_identifier: '20770576',
+ localized_period: '1 year',
+ },
+ region_code: 'US',
+ paywall_variation_id: '3968c273-f247-4b9f-bd90-305be39d6414',
+ price: {
+ currency_symbol: '$',
+ currency_code: 'USD',
+ amount: 69.99,
+ localized_string: '$69.99',
+ },
+ adapty_product_id: '4f930955-b0e4-47c3-8bb9-abd1bbdccabd',
+ paywall_name: 'rt.Short.Overlay.Video.DarkMode.Toggle2 (Copy)',
+ },
+ id: 'paywall_view_did_finish_purchase',
+} as const;
+
+/**
+ * Sample for PaywallViewEvent.DidPurchase with successful purchase result
+ * @see cross_platform.yaml#/$events/PaywallViewEvent.DidPurchase
+ */
+export const PAYWALL_PURCHASE_COMPLETED_SUCCESS = {
+ view: {
+ id: '88df97f8-ca94-43a4-bd4a-1749a89988e8',
+ placement_id: 'AdaptyRnSdkExample1',
+ variation_id: 'a24a2d05-93fe-4bcc-a76e-eef7690a436c',
+ },
+ product: {
+ access_level_id: 'premium',
+ is_family_shareable: false,
+ localized_description: 'sdfg',
+ localized_title: 'sdfg',
+ payload_data:
+ 'eyJjdXJyZW5jeV9jb2RlIjoiRVVSIiwicHJpY2VfYW1vdW50X21pY3JvcyI6MTE5OTAwMDAsInN1\nYnNjcmlwdGlvbl9kYXRhIjp7ImJhc2VfcGxhbl9pZCI6IndlZWtseS1wcmVtaXVtLTU5OS1iYXNl\nIiwib2ZmZXJfaWQiOm51bGx9LCJ0eXBlIjoic3VicyJ9\n',
+ paywall_ab_test_name: 'AdaptyRnSdkExample1',
+ paywall_name: 'AdaptyRnSdkExample1',
+ price: {
+ amount: 11.99,
+ currency_code: 'EUR',
+ currency_symbol: '€',
+ localized_string: '€11.99',
+ },
+ product_type: 'weekly',
+ subscription: {
+ base_plan_id: 'weekly-premium-599-base',
+ localized_period: '1 week',
+ offer_id: 'intro-pay-up-front-weekly-premium',
+ offer_tags: [],
+ renewal_type: 'autorenewable',
+ period: {
+ number_of_units: 1,
+ unit: 'week',
+ },
+ },
+ paywall_variation_id: 'a24a2d05-93fe-4bcc-a76e-eef7690a436c',
+ vendor_product_id: 'weekly.premium.599',
+ web_purchase_url:
+ 'http://paywalls-14c3d623-2f3a-455a-aa86-ef83dff6913b.fnlfx.com/trest2',
+ paywall_product_index: 0,
+ adapty_product_id: 'b136422f-8153-402a-afbb-986929c68f6a',
+ },
+ purchased_result: {
+ type: 'success',
+ google_purchase_token:
+ 'abcdefghijklmnopqrs.AO-J1Oy0BKdJa02G4rK5G7jxhSbdajFmsy3FSHTqLUwE1rApPKMSJaY_gZg6aIVgxPO10_GRD94ZCSKvoAp3EkqDLRLQ45W7-Q',
+ profile: {
+ paid_access_levels: {
+ premium: {
+ activated_at: '2025-12-26T13:36:09.931000+0000',
+ expires_at: '2025-12-26T13:41:09.549000+0000',
+ id: 'premium',
+ is_active: true,
+ is_in_grace_period: false,
+ is_lifetime: false,
+ is_refund: false,
+ renewed_at: '2025-12-26T13:36:09.931000+0000',
+ store: 'play_store',
+ vendor_product_id: 'weekly.premium.599',
+ will_renew: true,
+ },
+ },
+ custom_attributes: {},
+ is_test_user: false,
+ non_subscriptions: {},
+ profile_id: 'cbdabead-697c-4804-9ea5-7ccaa83411c7',
+ subscriptions: {
+ 'weekly.premium.599': {
+ activated_at: '2025-12-26T13:36:09.931000+0000',
+ expires_at: '2025-12-26T13:41:09.549000+0000',
+ is_active: true,
+ is_in_grace_period: false,
+ is_lifetime: false,
+ is_refund: false,
+ is_sandbox: true,
+ renewed_at: '2025-12-26T13:36:09.931000+0000',
+ store: 'play_store',
+ vendor_original_transaction_id: 'GPA.3338-3241-1006-23335',
+ vendor_product_id: 'weekly.premium.599',
+ vendor_transaction_id: 'GPA.3338-3241-1006-23335',
+ will_renew: true,
+ },
+ },
+ segment_hash: 'not implemented',
+ timestamp: -1,
+ },
+ },
+ id: 'paywall_view_did_finish_purchase',
+} as const;
+
+/**
+ * Sample for PaywallViewEvent.DidFailPurchase
+ * @see cross_platform.yaml#/$events/PaywallViewEvent.DidFailPurchase
+ */
+export const PAYWALL_PURCHASE_FAILED = {
+ view: {
+ id: '2064bb24-39e4-4c06-a9aa-4417357edfb4',
+ placement_id: 'test_placement',
+ variation_id: '61d30b4d-d92e-4494-8d78-f3b0f4356fae',
+ },
+ product: {
+ access_level_id: 'premium',
+ is_family_shareable: false,
+ localized_description: 'Description',
+ localized_title: 'Title',
+ payload_data:
+ 'eyJjdXJyZW5jeV9jb2RlIjoiRVVSIiwicHJpY2VfYW1vdW50X21pY3JvcyI6MTM5OTAwMDAsInN1\nYmNjcmlwdGlvbl9kYXRhIjp7ImJhc2VfcGxhbl9pZCI6Im1vbnRobHktcHJlbWl1bS05OTktYmFz\nZSIsIm9mZmVyX2lkIjpudWxsfSwidHlwZSI6InN1YnMifQ==\n',
+ paywall_ab_test_name: 'test_restore_button',
+ paywall_name: 'test_restore_button',
+ price: {
+ amount: 13.99,
+ currency_code: 'EUR',
+ currency_symbol: '€',
+ localized_string: '€13.99',
+ },
+ product_type: 'monthly',
+ subscription: {
+ base_plan_id: 'monthly-premium-999-base',
+ localized_period: '1 month',
+ offer_tags: [],
+ renewal_type: 'autorenewable',
+ period: {
+ number_of_units: 1,
+ unit: 'month',
+ },
+ },
+ paywall_variation_id: '61d30b4d-d92e-4494-8d78-f3b0f4356fae',
+ vendor_product_id: 'monthly.premium.999',
+ paywall_product_index: 0,
+ adapty_product_id: 'ac281b85-9294-4109-b9f1-4ab66b52d263',
+ },
+ error: {
+ adapty_code: 103,
+ message: 'Play Market request failed on purchases updated: responseCode=3',
+ },
+ id: 'paywall_view_did_fail_purchase',
+} as const;
+
+/**
+ * Sample for PaywallViewEvent.WillPurchase
+ * @see cross_platform.yaml#/$events/PaywallViewEvent.WillPurchase
+ */
+export const PAYWALL_PURCHASE_STARTED = {
+ product: {
+ subscription: {
+ offer: {
+ phases: [
+ {
+ localized_number_of_periods: '1 month',
+ price: {
+ amount: 0,
+ currency_code: 'USD',
+ localized_string: '$0.00',
+ },
+ localized_subscription_period: '1 month',
+ number_of_periods: 1,
+ payment_mode: 'free_trial',
+ subscription_period: {
+ unit: 'month',
+ number_of_units: 1,
+ },
+ },
+ ],
+ offer_identifier: {
+ type: 'introductory',
+ },
+ },
+ group_identifier: '20770576',
+ localized_period: '1 year',
+ period: {
+ unit: 'year',
+ number_of_units: 1,
+ },
+ },
+ region_code: 'US',
+ access_level_id: 'premium',
+ is_family_shareable: false,
+ localized_title: '1 Year Premium',
+ vendor_product_id: 'yearly.premium.6999',
+ localized_description: '1 Year Premium Description',
+ paywall_ab_test_name: 'rt.Short.Overlay.Video.DarkMode.Toggle2 (Copy)',
+ price: {
+ currency_code: 'USD',
+ currency_symbol: '$',
+ localized_string: '$69.99',
+ amount: 69.99,
+ },
+ paywall_product_index: 1,
+ paywall_name: 'rt.Short.Overlay.Video.DarkMode.Toggle2 (Copy)',
+ product_type: 'annual',
+ paywall_variation_id: '3968c273-f247-4b9f-bd90-305be39d6414',
+ adapty_product_id: '4f930955-b0e4-47c3-8bb9-abd1bbdccabd',
+ },
+ view: {
+ variation_id: '3968c273-f247-4b9f-bd90-305be39d6414',
+ placement_id: '3968c273-f247-4b9f-bd90-305be39d6414',
+ id: '9EC086AC-BE4F-4FB2-AABE-8AD31AF03BDF',
+ },
+ id: 'paywall_view_did_start_purchase',
+} as const;
+
+/**
+ * Sample for PaywallViewEvent.DidAppear
+ * @see cross_platform.yaml#/$events/PaywallViewEvent.DidAppear
+ */
+export const PAYWALL_VIEW_APPEARED = {
+ id: 'paywall_view_did_appear',
+ view: {
+ id: '9EC086AC-BE4F-4FB2-AABE-8AD31AF03BDF',
+ placement_id: '3968c273-f247-4b9f-bd90-305be39d6414',
+ variation_id: '3968c273-f247-4b9f-bd90-305be39d6414',
+ },
+} as const;
+
+/**
+ * Sample for PaywallViewEvent.WillRestorePurchase
+ * @see cross_platform.yaml#/$events/PaywallViewEvent.WillRestorePurchase
+ */
+export const PAYWALL_RESTORE_STARTED = {
+ view: {
+ id: '3980be37-7a25-4c38-aace-68ee46b2927c',
+ placement_id: 'test_placement',
+ variation_id: '61d30b4d-d92e-4494-8d78-f3b0f4356fae',
+ },
+ id: 'paywall_view_did_start_restore',
+} as const;
+
+/**
+ * Sample for PaywallViewEvent.DidRestorePurchase with successful restore
+ * @see cross_platform.yaml#/$events/PaywallViewEvent.DidRestorePurchase
+ */
+export const PAYWALL_RESTORE_COMPLETED_SUCCESS = {
+ view: {
+ id: '3980be37-7a25-4c38-aace-68ee46b2927c',
+ placement_id: 'test_placement',
+ variation_id: '61d30b4d-d92e-4494-8d78-f3b0f4356fae',
+ },
+ profile: {
+ paid_access_levels: {
+ premium: {
+ activated_at: '2025-12-26T14:15:01.977000+0000',
+ active_introductory_offer_type: 'free_trial',
+ expires_at: '2025-12-26T14:18:01.719000+0000',
+ id: 'premium',
+ is_active: true,
+ is_in_grace_period: false,
+ is_lifetime: false,
+ is_refund: false,
+ offer_id: 'intro-free-trial-yearly',
+ renewed_at: '2025-12-26T14:15:01.977000+0000',
+ store: 'play_store',
+ vendor_product_id: 'yearly.premium.6999',
+ will_renew: true,
+ },
+ },
+ custom_attributes: {},
+ is_test_user: false,
+ non_subscriptions: {},
+ profile_id: 'd200c008-13fd-4557-9c51-cff73b45a7f2',
+ subscriptions: {
+ 'yearly.premium.6999': {
+ activated_at: '2025-12-26T14:15:01.977000+0000',
+ active_introductory_offer_type: 'free_trial',
+ expires_at: '2025-12-26T14:18:01.719000+0000',
+ is_active: true,
+ is_in_grace_period: false,
+ is_lifetime: false,
+ is_refund: false,
+ is_sandbox: true,
+ offer_id: 'intro-free-trial-yearly',
+ renewed_at: '2025-12-26T14:15:01.977000+0000',
+ store: 'play_store',
+ vendor_original_transaction_id: 'GPA.3372-6866-7337-08302',
+ vendor_product_id: 'yearly.premium.6999',
+ vendor_transaction_id: 'GPA.3372-6866-7337-08302',
+ will_renew: true,
+ },
+ 'weekly.premium.599': {
+ activated_at: '2025-12-26T13:36:09.931000+0000',
+ cancellation_reason: 'unknown',
+ expires_at: '2025-12-26T13:41:09.549000+0000',
+ is_active: false,
+ is_in_grace_period: false,
+ is_lifetime: false,
+ is_refund: false,
+ is_sandbox: true,
+ renewed_at: '2025-12-26T13:36:09.931000+0000',
+ store: 'play_store',
+ vendor_original_transaction_id: 'GPA.3338-3241-1006-23335',
+ vendor_product_id: 'weekly.premium.599',
+ vendor_transaction_id: 'GPA.3338-3241-1006-23335',
+ will_renew: false,
+ },
+ },
+ segment_hash: 'not implemented',
+ timestamp: -1,
+ },
+ id: 'paywall_view_did_finish_restore',
+} as const;
+
+/**
+ * Sample for PaywallViewEvent.DidFailRestorePurchase
+ * @see cross_platform.yaml#/$events/PaywallViewEvent.DidFailRestorePurchase
+ */
+export const PAYWALL_RESTORE_FAILED = {
+ view: {
+ id: '0316c394-5e33-49bf-8fdd-49f07d74c065',
+ placement_id: 'test_placement',
+ variation_id: '61d30b4d-d92e-4494-8d78-f3b0f4356fae',
+ },
+ error: {
+ adapty_code: 103,
+ message:
+ 'Play Market request failed: Billing service unavailable on device.',
+ },
+ id: 'paywall_view_did_fail_restore',
+} as const;
+
+/**
+ * Sample for PaywallViewEvent.DidFailLoadingProducts
+ * @see cross_platform.yaml#/$events/PaywallViewEvent.DidFailLoadingProducts
+ */
+export const PAYWALL_LOADING_PRODUCTS_FAILED = {
+ view: {
+ id: '0316c394-5e33-49bf-8fdd-49f07d74c065',
+ placement_id: 'test_placement',
+ variation_id: '61d30b4d-d92e-4494-8d78-f3b0f4356fae',
+ },
+ error: {
+ adapty_code: 103,
+ message:
+ 'Play Market request failed: Billing service unavailable on device.',
+ },
+ id: 'paywall_view_did_fail_loading_products',
+} as const;
+
+/**
+ * Sample for PaywallViewEvent.DidFailRendering
+ * @see cross_platform.yaml#/$events/PaywallViewEvent.DidFailRendering
+ */
+export const PAYWALL_RENDERING_FAILED = {
+ view: {
+ id: '2064bb24-39e4-4c06-a9aa-4417357edfb4',
+ placement_id: 'test_placement',
+ variation_id: '61d30b4d-d92e-4494-8d78-f3b0f4356fae',
+ },
+ error: {
+ adapty_code: 23,
+ message:
+ 'The paywall JSON is not valid. Fix it in the Adapty Dashboard. Refer to the Customize paywall with remote config topic for details on how to fix it.',
+ },
+ id: 'paywall_view_did_fail_rendering',
+} as const;
+
+/**
+ * Sample for PaywallViewEvent.DidFinishWebPaymentNavigation
+ * @see cross_platform.yaml#/$events/PaywallViewEvent.DidFinishWebPaymentNavigation
+ */
+export const PAYWALL_WEB_PAYMENT_NAVIGATION_FINISHED = {
+ view: {
+ id: '2442A0E9-FB7F-4369-87BB-61C80222AFA1',
+ variation_id: '5b4f588f-1ea3-4000-9de9-0e82e2fe7a48',
+ placement_id: '5b4f588f-1ea3-4000-9de9-0e82e2fe7a48',
+ },
+ product: {
+ region_code: 'US',
+ product_type: 'semiannual',
+ paywall_name: 'rt.web',
+ vendor_product_id: 'sixmonth.premium.999',
+ localized_title: 'Six Months Premium',
+ web_purchase_url: 'https://example.com',
+ access_level_id: 'premium',
+ paywall_ab_test_name: 'rt.web',
+ subscription: {
+ period: {
+ unit: 'month',
+ number_of_units: 6,
+ },
+ group_identifier: '20770576',
+ localized_period: '6 months',
+ offer: null,
+ },
+ localized_description: 'Six Months Premium Description',
+ adapty_product_id: '0f2e86da-4d6f-435e-bc8f-9f2d1c265a27',
+ paywall_variation_id: '5b4f588f-1ea3-4000-9de9-0e82e2fe7a48',
+ price: {
+ currency_symbol: '$',
+ localized_string: '$14.99',
+ currency_code: 'USD',
+ amount: 14.99,
+ },
+ is_family_shareable: false,
+ paywall_product_index: 0,
+ },
+ id: 'paywall_view_did_finish_web_payment_navigation',
+} as const;
+
+/**
+ * Common product data for yearly premium
+ */
+export const COMMON_PRODUCT_YEARLY = {
+ vendor_product_id: 'yearly.premium.6999',
+ adapty_product_id: '4f930955-b0e4-47c3-8bb9-abd1bbdccabd',
+ access_level_id: 'premium',
+ product_type: 'annual',
+ localized_title: '1 Year Premium',
+ localized_description: '1 Year Premium Description',
+ region_code: 'US',
+ is_family_shareable: false,
+ paywall_product_index: 1,
+ paywall_name: 'rt.Short.Overlay.Video.DarkMode.Toggle2 (Copy)',
+ paywall_ab_test_name: 'rt.Short.Overlay.Video.DarkMode.Toggle2 (Copy)',
+ paywall_variation_id: '3968c273-f247-4b9f-bd90-305be39d6414',
+ price: {
+ amount: 69.99,
+ currency_code: 'USD',
+ currency_symbol: '$',
+ localized_string: '$69.99',
+ },
+ subscription: {
+ period: {
+ unit: 'year',
+ number_of_units: 1,
+ },
+ localized_period: '1 year',
+ group_identifier: '20770576',
+ offer: {
+ offer_identifier: {
+ type: 'introductory',
+ },
+ phases: [
+ {
+ number_of_periods: 1,
+ payment_mode: 'free_trial',
+ localized_number_of_periods: '1 month',
+ localized_subscription_period: '1 month',
+ subscription_period: {
+ unit: 'month',
+ number_of_units: 1,
+ },
+ price: {
+ amount: 0,
+ currency_code: 'USD',
+ localized_string: '$0.00',
+ },
+ },
+ ],
+ },
+ },
+} as const;
diff --git a/src/__tests__/integration/ui/paywall/paywall-event-emitter.utils.ts b/src/__tests__/integration/ui/paywall/paywall-event-emitter.utils.ts
new file mode 100644
index 00000000..eb940b7e
--- /dev/null
+++ b/src/__tests__/integration/ui/paywall/paywall-event-emitter.utils.ts
@@ -0,0 +1,459 @@
+import { $bridge } from '@/bridge';
+
+/**
+ * Paywall Event Emitters for Testing
+ *
+ * These functions emit mock native paywall events in the correct format (snake_case)
+ * to test event handling in PaywallViewController.
+ */
+
+/**
+ * Emits mock paywall product selected event for testing
+ */
+export function emitPaywallProductSelectedEvent(
+ viewId: string,
+ productId: string,
+ view: { id: string; placement_id: string; variation_id: string },
+): void {
+ const bridge = $bridge.testBridge;
+
+ if (!bridge) {
+ throw new Error('Bridge not initialized');
+ }
+
+ const emitter = (bridge as any).testEmitter;
+
+ if (!emitter) {
+ throw new Error('Mock emitter not available. Ensure mock mode is enabled.');
+ }
+
+ const payload = {
+ id: 'paywall_view_did_select_product',
+ view: {
+ id: viewId,
+ placement_id: view.placement_id,
+ variation_id: view.variation_id,
+ },
+ product_id: productId,
+ };
+
+ emitter.emit('paywall_view_did_select_product', JSON.stringify(payload));
+}
+
+/**
+ * Emits mock paywall user action event for testing
+ * Universal function for close/system_back/open_url/custom actions
+ */
+export function emitPaywallUserActionEvent(
+ viewId: string,
+ actionType: 'close' | 'system_back' | 'open_url' | 'custom',
+ actionValue: string | undefined,
+ view: { id: string; placement_id: string; variation_id: string },
+): void {
+ const bridge = $bridge.testBridge;
+
+ if (!bridge) {
+ throw new Error('Bridge not initialized');
+ }
+
+ const emitter = (bridge as any).testEmitter;
+
+ if (!emitter) {
+ throw new Error('Mock emitter not available. Ensure mock mode is enabled.');
+ }
+
+ const action: Record = {
+ type: actionType,
+ };
+
+ if (actionValue !== undefined) {
+ action['value'] = actionValue;
+ }
+
+ const payload = {
+ id: 'paywall_view_did_perform_action',
+ view: {
+ id: viewId,
+ placement_id: view.placement_id,
+ variation_id: view.variation_id,
+ },
+ action,
+ };
+
+ emitter.emit('paywall_view_did_perform_action', JSON.stringify(payload));
+}
+
+/**
+ * Emits mock paywall purchase started event for testing
+ */
+export function emitPaywallPurchaseStartedEvent(
+ viewId: string,
+ product: Record,
+ view: { id: string; placement_id: string; variation_id: string },
+): void {
+ const bridge = $bridge.testBridge;
+
+ if (!bridge) {
+ throw new Error('Bridge not initialized');
+ }
+
+ const emitter = (bridge as any).testEmitter;
+
+ if (!emitter) {
+ throw new Error('Mock emitter not available. Ensure mock mode is enabled.');
+ }
+
+ const payload = {
+ id: 'paywall_view_did_start_purchase',
+ view: {
+ id: viewId,
+ placement_id: view.placement_id,
+ variation_id: view.variation_id,
+ },
+ product,
+ };
+
+ emitter.emit('paywall_view_did_start_purchase', JSON.stringify(payload));
+}
+
+/**
+ * Emits mock paywall purchase completed event for testing
+ */
+export function emitPaywallPurchaseCompletedEvent(
+ viewId: string,
+ purchasedResult: Record,
+ product: Record,
+ view: { id: string; placement_id: string; variation_id: string },
+): void {
+ const bridge = $bridge.testBridge;
+
+ if (!bridge) {
+ throw new Error('Bridge not initialized');
+ }
+
+ const emitter = (bridge as any).testEmitter;
+
+ if (!emitter) {
+ throw new Error('Mock emitter not available. Ensure mock mode is enabled.');
+ }
+
+ const payload = {
+ id: 'paywall_view_did_finish_purchase',
+ view: {
+ id: viewId,
+ placement_id: view.placement_id,
+ variation_id: view.variation_id,
+ },
+ purchased_result: purchasedResult,
+ product,
+ };
+
+ emitter.emit('paywall_view_did_finish_purchase', JSON.stringify(payload));
+}
+
+/**
+ * Emits mock paywall purchase failed event for testing
+ */
+export function emitPaywallPurchaseFailedEvent(
+ viewId: string,
+ error: { adapty_code: number; message: string },
+ product: Record,
+ view: { id: string; placement_id: string; variation_id: string },
+): void {
+ const bridge = $bridge.testBridge;
+
+ if (!bridge) {
+ throw new Error('Bridge not initialized');
+ }
+
+ const emitter = (bridge as any).testEmitter;
+
+ if (!emitter) {
+ throw new Error('Mock emitter not available. Ensure mock mode is enabled.');
+ }
+
+ const payload = {
+ id: 'paywall_view_did_fail_purchase',
+ view: {
+ id: viewId,
+ placement_id: view.placement_id,
+ variation_id: view.variation_id,
+ },
+ error,
+ product,
+ };
+
+ emitter.emit('paywall_view_did_fail_purchase', JSON.stringify(payload));
+}
+
+/**
+ * Emits mock paywall restore started event for testing
+ */
+export function emitPaywallRestoreStartedEvent(
+ viewId: string,
+ view: { id: string; placement_id: string; variation_id: string },
+): void {
+ const bridge = $bridge.testBridge;
+
+ if (!bridge) {
+ throw new Error('Bridge not initialized');
+ }
+
+ const emitter = (bridge as any).testEmitter;
+
+ if (!emitter) {
+ throw new Error('Mock emitter not available. Ensure mock mode is enabled.');
+ }
+
+ const payload = {
+ id: 'paywall_view_did_start_restore',
+ view: {
+ id: viewId,
+ placement_id: view.placement_id,
+ variation_id: view.variation_id,
+ },
+ };
+
+ emitter.emit('paywall_view_did_start_restore', JSON.stringify(payload));
+}
+
+/**
+ * Emits mock paywall restore completed event for testing
+ */
+export function emitPaywallRestoreCompletedEvent(
+ viewId: string,
+ profile: Record,
+ view: { id: string; placement_id: string; variation_id: string },
+): void {
+ const bridge = $bridge.testBridge;
+
+ if (!bridge) {
+ throw new Error('Bridge not initialized');
+ }
+
+ const emitter = (bridge as any).testEmitter;
+
+ if (!emitter) {
+ throw new Error('Mock emitter not available. Ensure mock mode is enabled.');
+ }
+
+ const payload = {
+ id: 'paywall_view_did_finish_restore',
+ view: {
+ id: viewId,
+ placement_id: view.placement_id,
+ variation_id: view.variation_id,
+ },
+ profile,
+ };
+
+ emitter.emit('paywall_view_did_finish_restore', JSON.stringify(payload));
+}
+
+/**
+ * Emits mock paywall restore failed event for testing
+ */
+export function emitPaywallRestoreFailedEvent(
+ viewId: string,
+ error: { adapty_code: number; message: string },
+ view: { id: string; placement_id: string; variation_id: string },
+): void {
+ const bridge = $bridge.testBridge;
+
+ if (!bridge) {
+ throw new Error('Bridge not initialized');
+ }
+
+ const emitter = (bridge as any).testEmitter;
+
+ if (!emitter) {
+ throw new Error('Mock emitter not available. Ensure mock mode is enabled.');
+ }
+
+ const payload = {
+ id: 'paywall_view_did_fail_restore',
+ view: {
+ id: viewId,
+ placement_id: view.placement_id,
+ variation_id: view.variation_id,
+ },
+ error,
+ };
+
+ emitter.emit('paywall_view_did_fail_restore', JSON.stringify(payload));
+}
+
+/**
+ * Emits mock paywall view appeared event for testing
+ */
+export function emitPaywallViewAppearedEvent(
+ viewId: string,
+ view: { id: string; placement_id: string; variation_id: string },
+): void {
+ const bridge = $bridge.testBridge;
+
+ if (!bridge) {
+ throw new Error('Bridge not initialized');
+ }
+
+ const emitter = (bridge as any).testEmitter;
+
+ if (!emitter) {
+ throw new Error('Mock emitter not available. Ensure mock mode is enabled.');
+ }
+
+ const payload = {
+ id: 'paywall_view_did_appear',
+ view: {
+ id: viewId,
+ placement_id: view.placement_id,
+ variation_id: view.variation_id,
+ },
+ };
+
+ emitter.emit('paywall_view_did_appear', JSON.stringify(payload));
+}
+
+/**
+ * Emits mock paywall view disappeared event for testing
+ */
+export function emitPaywallViewDisappearedEvent(
+ viewId: string,
+ view: { id: string; placement_id: string; variation_id: string },
+): void {
+ const bridge = $bridge.testBridge;
+
+ if (!bridge) {
+ throw new Error('Bridge not initialized');
+ }
+
+ const emitter = (bridge as any).testEmitter;
+
+ if (!emitter) {
+ throw new Error('Mock emitter not available. Ensure mock mode is enabled.');
+ }
+
+ const payload = {
+ id: 'paywall_view_did_disappear',
+ view: {
+ id: viewId,
+ placement_id: view.placement_id,
+ variation_id: view.variation_id,
+ },
+ };
+
+ emitter.emit('paywall_view_did_disappear', JSON.stringify(payload));
+}
+
+/**
+ * Emits mock paywall web payment navigation finished event for testing
+ */
+export function emitPaywallWebPaymentNavigationFinishedEvent(
+ viewId: string,
+ product: Record | undefined,
+ error: { adapty_code: number; message: string } | undefined,
+ view: { id: string; placement_id: string; variation_id: string },
+): void {
+ const bridge = $bridge.testBridge;
+
+ if (!bridge) {
+ throw new Error('Bridge not initialized');
+ }
+
+ const emitter = (bridge as any).testEmitter;
+
+ if (!emitter) {
+ throw new Error('Mock emitter not available. Ensure mock mode is enabled.');
+ }
+
+ const payload: Record = {
+ id: 'paywall_view_did_finish_web_payment_navigation',
+ view: {
+ id: viewId,
+ placement_id: view.placement_id,
+ variation_id: view.variation_id,
+ },
+ };
+
+ if (product !== undefined) {
+ payload['product'] = product;
+ }
+
+ if (error !== undefined) {
+ payload['error'] = error;
+ }
+
+ emitter.emit(
+ 'paywall_view_did_finish_web_payment_navigation',
+ JSON.stringify(payload),
+ );
+}
+
+/**
+ * Emits mock paywall rendering failed event for testing
+ */
+export function emitPaywallRenderingFailedEvent(
+ viewId: string,
+ error: { adapty_code: number; message: string },
+ view: { id: string; placement_id: string; variation_id: string },
+): void {
+ const bridge = $bridge.testBridge;
+
+ if (!bridge) {
+ throw new Error('Bridge not initialized');
+ }
+
+ const emitter = (bridge as any).testEmitter;
+
+ if (!emitter) {
+ throw new Error('Mock emitter not available. Ensure mock mode is enabled.');
+ }
+
+ const payload = {
+ id: 'paywall_view_did_fail_rendering',
+ view: {
+ id: viewId,
+ placement_id: view.placement_id,
+ variation_id: view.variation_id,
+ },
+ error,
+ };
+
+ emitter.emit('paywall_view_did_fail_rendering', JSON.stringify(payload));
+}
+
+/**
+ * Emits mock paywall loading products failed event for testing
+ */
+export function emitPaywallLoadingProductsFailedEvent(
+ viewId: string,
+ error: { adapty_code: number; message: string },
+ view: { id: string; placement_id: string; variation_id: string },
+): void {
+ const bridge = $bridge.testBridge;
+
+ if (!bridge) {
+ throw new Error('Bridge not initialized');
+ }
+
+ const emitter = (bridge as any).testEmitter;
+
+ if (!emitter) {
+ throw new Error('Mock emitter not available. Ensure mock mode is enabled.');
+ }
+
+ const payload = {
+ id: 'paywall_view_did_fail_loading_products',
+ view: {
+ id: viewId,
+ placement_id: view.placement_id,
+ variation_id: view.variation_id,
+ },
+ error,
+ };
+
+ emitter.emit(
+ 'paywall_view_did_fail_loading_products',
+ JSON.stringify(payload),
+ );
+}
diff --git a/src/__tests__/integration/ui/paywall/paywall-view-controller-events.test.ts b/src/__tests__/integration/ui/paywall/paywall-view-controller-events.test.ts
new file mode 100644
index 00000000..87a1a9ae
--- /dev/null
+++ b/src/__tests__/integration/ui/paywall/paywall-view-controller-events.test.ts
@@ -0,0 +1,1685 @@
+/**
+ * Integration tests for Paywall ViewController events
+ *
+ * This file contains tests for platform-independent fields only.
+ * Platform-specific fields are tested in separate files:
+ * - ios-paywall-view-controller-events.test.ts - iOS-specific fields (isFamilyShareable, subscriptionGroupIdentifier, appleJwsTransaction)
+ * - android-paywall-view-controller-events.test.ts - Android-specific fields (basePlanId, renewalType, googlePurchaseToken)
+ *
+ * Platform-independent fields tested here:
+ * - Product: vendorProductId, adaptyId, localizedTitle, localizedDescription, price, subscription.period
+ * - PurchaseResult: type, profile (structure only)
+ * - Profile: profileId, subscriptions (structure only, not store values), accessLevels (structure only, not store values)
+ * - Error: adaptyCode, message
+ */
+
+import { Adapty } from '@/adapty-handler';
+import { AdaptyError } from '@/adapty-error';
+import { ViewController } from '@/ui/view-controller';
+import { EventHandlers } from '@/ui/types';
+import {
+ createPaywallViewController,
+ cleanupPaywallViewController,
+} from '../setup.utils';
+import {
+ emitPaywallProductSelectedEvent,
+ emitPaywallUserActionEvent,
+ emitPaywallPurchaseStartedEvent,
+ emitPaywallPurchaseCompletedEvent,
+ emitPaywallPurchaseFailedEvent,
+ emitPaywallRestoreStartedEvent,
+ emitPaywallRestoreCompletedEvent,
+ emitPaywallRestoreFailedEvent,
+ emitPaywallViewAppearedEvent,
+ emitPaywallViewDisappearedEvent,
+ emitPaywallWebPaymentNavigationFinishedEvent,
+ emitPaywallRenderingFailedEvent,
+ emitPaywallLoadingProductsFailedEvent,
+} from './paywall-event-emitter.utils';
+import {
+ PAYWALL_PRODUCT_SELECTED_YEARLY,
+ PAYWALL_USER_ACTION_CLOSE,
+ PAYWALL_USER_ACTION_OPEN_URL,
+ PAYWALL_USER_ACTION_SYSTEM_BACK,
+ PAYWALL_USER_ACTION_CLOSE_BUTTON,
+ PAYWALL_PURCHASE_STARTED,
+ PAYWALL_PURCHASE_COMPLETED_SUCCESS,
+ PAYWALL_PURCHASE_COMPLETED_CANCELLED,
+ PAYWALL_PURCHASE_FAILED,
+ PAYWALL_RESTORE_STARTED,
+ PAYWALL_RESTORE_COMPLETED_SUCCESS,
+ PAYWALL_RESTORE_FAILED,
+ PAYWALL_VIEW_APPEARED,
+ PAYWALL_VIEW_DISAPPEARED,
+ PAYWALL_WEB_PAYMENT_NAVIGATION_FINISHED,
+ PAYWALL_RENDERING_FAILED,
+ PAYWALL_LOADING_PRODUCTS_FAILED,
+} from './paywall-bridge-event-samples';
+
+describe('ViewController - action mapping isolation', () => {
+ let adapty: Adapty;
+ let view: ViewController;
+
+ beforeEach(async () => {
+ const result = await createPaywallViewController();
+ adapty = result.adapty;
+ view = result.view;
+ });
+
+ afterEach(() => {
+ cleanupPaywallViewController(view, adapty);
+ });
+
+ it('should call ONLY onCloseButtonPress when action.type is "close"', async () => {
+ // Register all 4 handlers for the same native event
+ const onCloseHandler: jest.MockedFunction<
+ EventHandlers['onCloseButtonPress']
+ > = jest.fn().mockReturnValue(false);
+ const onSystemBackHandler: jest.MockedFunction<
+ EventHandlers['onAndroidSystemBack']
+ > = jest.fn().mockReturnValue(false);
+ const onUrlPressHandler: jest.MockedFunction =
+ jest.fn().mockReturnValue(false);
+ const onCustomHandler: jest.MockedFunction<
+ EventHandlers['onCustomAction']
+ > = jest.fn().mockReturnValue(false);
+
+ view.setEventHandlers({
+ onCloseButtonPress: onCloseHandler,
+ onAndroidSystemBack: onSystemBackHandler,
+ onUrlPress: onUrlPressHandler,
+ onCustomAction: onCustomHandler,
+ });
+
+ const viewId = (view as any).id;
+ const sample = PAYWALL_USER_ACTION_CLOSE;
+
+ // Emit event with action.type = 'close'
+ emitPaywallUserActionEvent(viewId, 'close', undefined, sample.view);
+
+ // ONLY onCloseButtonPress should be called
+ expect(onCloseHandler).toHaveBeenCalledTimes(1);
+
+ // Others should NOT be called
+ expect(onSystemBackHandler).not.toHaveBeenCalled();
+ expect(onUrlPressHandler).not.toHaveBeenCalled();
+ expect(onCustomHandler).not.toHaveBeenCalled();
+ });
+
+ it('should call ONLY onUrlPress when action.type is "open_url"', async () => {
+ const onCloseHandler: jest.MockedFunction<
+ EventHandlers['onCloseButtonPress']
+ > = jest.fn().mockReturnValue(false);
+ const onUrlPressHandler: jest.MockedFunction =
+ jest.fn().mockReturnValue(false);
+ const onCustomHandler: jest.MockedFunction<
+ EventHandlers['onCustomAction']
+ > = jest.fn().mockReturnValue(false);
+
+ view.setEventHandlers({
+ onCloseButtonPress: onCloseHandler,
+ onUrlPress: onUrlPressHandler,
+ onCustomAction: onCustomHandler,
+ });
+
+ const viewId = (view as any).id;
+ const sample = PAYWALL_USER_ACTION_OPEN_URL;
+
+ // Emit event with action.type = 'open_url' and value = URL
+ emitPaywallUserActionEvent(
+ viewId,
+ 'open_url',
+ sample.action.value,
+ sample.view,
+ );
+
+ // ONLY onUrlPress should be called with URL
+ expect(onUrlPressHandler).toHaveBeenCalledTimes(1);
+ expect(onUrlPressHandler).toHaveBeenCalledWith(sample.action.value);
+
+ // Others should NOT be called
+ expect(onCloseHandler).not.toHaveBeenCalled();
+ expect(onCustomHandler).not.toHaveBeenCalled();
+ });
+
+ it('should call ONLY onAndroidSystemBack when action.type is "system_back"', async () => {
+ const onCloseHandler: jest.MockedFunction<
+ EventHandlers['onCloseButtonPress']
+ > = jest.fn().mockReturnValue(false);
+ const onSystemBackHandler: jest.MockedFunction<
+ EventHandlers['onAndroidSystemBack']
+ > = jest.fn().mockReturnValue(false);
+ const onUrlPressHandler: jest.MockedFunction =
+ jest.fn().mockReturnValue(false);
+
+ view.setEventHandlers({
+ onCloseButtonPress: onCloseHandler,
+ onAndroidSystemBack: onSystemBackHandler,
+ onUrlPress: onUrlPressHandler,
+ });
+
+ const viewId = (view as any).id;
+ const sample = PAYWALL_USER_ACTION_SYSTEM_BACK;
+
+ // Emit event with action.type = 'system_back'
+ emitPaywallUserActionEvent(viewId, 'system_back', undefined, sample.view);
+
+ // ONLY onAndroidSystemBack should be called
+ expect(onSystemBackHandler).toHaveBeenCalledTimes(1);
+
+ // Others should NOT be called
+ expect(onCloseHandler).not.toHaveBeenCalled();
+ expect(onUrlPressHandler).not.toHaveBeenCalled();
+ });
+
+ it('should call ONLY onCustomAction when action.type is "custom"', async () => {
+ const onCloseHandler: jest.MockedFunction<
+ EventHandlers['onCloseButtonPress']
+ > = jest.fn().mockReturnValue(false);
+ const onCustomHandler: jest.MockedFunction<
+ EventHandlers['onCustomAction']
+ > = jest.fn().mockReturnValue(false);
+
+ view.setEventHandlers({
+ onCloseButtonPress: onCloseHandler,
+ onCustomAction: onCustomHandler,
+ });
+
+ const viewId = (view as any).id;
+ const sample = PAYWALL_USER_ACTION_CLOSE; // Reuse sample but change action type
+
+ // Emit event with action.type = 'custom' and custom value
+ emitPaywallUserActionEvent(
+ viewId,
+ 'custom',
+ 'custom_action_value',
+ sample.view,
+ );
+
+ // ONLY onCustomAction should be called with the value
+ expect(onCustomHandler).toHaveBeenCalledTimes(1);
+ expect(onCustomHandler).toHaveBeenCalledWith('custom_action_value');
+
+ // onCloseButtonPress should NOT be called
+ expect(onCloseHandler).not.toHaveBeenCalled();
+ });
+});
+
+describe('ViewController - onProductSelected event', () => {
+ let adapty: Adapty;
+ let view: ViewController;
+
+ beforeEach(async () => {
+ const result = await createPaywallViewController();
+ adapty = result.adapty;
+ view = result.view;
+ });
+
+ afterEach(() => {
+ cleanupPaywallViewController(view, adapty);
+ });
+
+ it('should call onProductSelected handler when product is selected', async () => {
+ const handler: jest.MockedFunction =
+ jest.fn().mockReturnValue(false);
+
+ view.setEventHandlers({ onProductSelected: handler });
+
+ const viewId = (view as any).id;
+ const sample = PAYWALL_PRODUCT_SELECTED_YEARLY;
+
+ emitPaywallProductSelectedEvent(viewId, sample.product_id, sample.view);
+
+ expect(handler).toHaveBeenCalledTimes(1);
+ expect(handler).toHaveBeenCalledWith(sample.product_id);
+ });
+
+ it('should filter events by viewId', async () => {
+ const handler: jest.MockedFunction =
+ jest.fn().mockReturnValue(false);
+
+ view.setEventHandlers({ onProductSelected: handler });
+
+ const sample = PAYWALL_PRODUCT_SELECTED_YEARLY;
+
+ // Emit event for DIFFERENT view
+ emitPaywallProductSelectedEvent(
+ 'different_view_id',
+ sample.product_id,
+ sample.view,
+ );
+
+ // Handler should NOT be called
+ expect(handler).not.toHaveBeenCalled();
+ });
+});
+
+describe('ViewController - onPurchaseStarted event', () => {
+ let adapty: Adapty;
+ let view: ViewController;
+
+ beforeEach(async () => {
+ const result = await createPaywallViewController();
+ adapty = result.adapty;
+ view = result.view;
+ });
+
+ afterEach(() => {
+ cleanupPaywallViewController(view, adapty);
+ });
+
+ it('should call onPurchaseStarted handler when purchase starts', async () => {
+ // NOTE: This test verifies platform-independent fields only.
+ // Platform-specific fields (iOS: isFamilyShareable, subscriptionGroupIdentifier;
+ // Android: basePlanId, renewalType) are tested in platform-specific test files.
+ const handler: jest.MockedFunction =
+ jest.fn().mockReturnValue(false);
+
+ view.setEventHandlers({ onPurchaseStarted: handler });
+
+ const viewId = (view as any).id;
+ const sample = PAYWALL_PURCHASE_STARTED;
+
+ emitPaywallPurchaseStartedEvent(viewId, sample.product, sample.view);
+
+ expect(handler).toHaveBeenCalledTimes(1);
+ const [product] = handler.mock.calls[0]!;
+
+ // Platform-independent product fields
+ expect(product).toHaveProperty(
+ 'vendorProductId',
+ sample.product.vendor_product_id,
+ );
+ expect(product).toHaveProperty(
+ 'adaptyId',
+ sample.product.adapty_product_id,
+ );
+ expect(product).toHaveProperty(
+ 'localizedTitle',
+ sample.product.localized_title,
+ );
+ expect(product).toHaveProperty(
+ 'localizedDescription',
+ sample.product.localized_description,
+ );
+ expect(product).toHaveProperty(
+ 'accessLevelId',
+ sample.product.access_level_id,
+ );
+ expect(product).toHaveProperty('productType', sample.product.product_type);
+
+ // Platform-independent price fields
+ expect(product).toHaveProperty('price');
+ expect(product.price).toHaveProperty('amount', sample.product.price.amount);
+ expect(product.price).toHaveProperty(
+ 'currencyCode',
+ sample.product.price.currency_code,
+ );
+ expect(product.price).toHaveProperty(
+ 'currencySymbol',
+ sample.product.price.currency_symbol,
+ );
+ expect(product.price).toHaveProperty(
+ 'localizedString',
+ sample.product.price.localized_string,
+ );
+
+ // Platform-independent subscription fields
+ if (product.subscription) {
+ expect(product.subscription).toHaveProperty('subscriptionPeriod');
+ expect(product.subscription.subscriptionPeriod).toHaveProperty(
+ 'unit',
+ sample.product.subscription.period.unit,
+ );
+ expect(product.subscription.subscriptionPeriod).toHaveProperty(
+ 'numberOfUnits',
+ sample.product.subscription.period.number_of_units,
+ );
+ expect(product.subscription).toHaveProperty(
+ 'localizedSubscriptionPeriod',
+ sample.product.subscription.localized_period,
+ );
+ }
+ });
+});
+
+describe('ViewController - onPurchaseCompleted event', () => {
+ let adapty: Adapty;
+ let view: ViewController;
+
+ beforeEach(async () => {
+ const result = await createPaywallViewController();
+ adapty = result.adapty;
+ view = result.view;
+ });
+
+ afterEach(() => {
+ cleanupPaywallViewController(view, adapty);
+ });
+
+ it('should call onPurchaseCompleted handler with success result', async () => {
+ // NOTE: This test verifies platform-independent fields only.
+ // Platform-specific fields (iOS: appleJwsTransaction; Android: googlePurchaseToken)
+ // are tested in platform-specific test files.
+ const handler: jest.MockedFunction =
+ jest.fn().mockReturnValue(false);
+
+ view.setEventHandlers({ onPurchaseCompleted: handler });
+
+ const viewId = (view as any).id;
+ const sample = PAYWALL_PURCHASE_COMPLETED_SUCCESS;
+
+ emitPaywallPurchaseCompletedEvent(
+ viewId,
+ sample.purchased_result,
+ sample.product,
+ sample.view,
+ );
+
+ expect(handler).toHaveBeenCalledTimes(1);
+ const [purchaseResult, product] = handler.mock.calls[0]!;
+
+ // Platform-independent purchase result fields
+ expect(purchaseResult.type).toBe('success');
+ if (purchaseResult.type === 'success') {
+ expect(purchaseResult).toHaveProperty('profile');
+ expect(purchaseResult.profile).toHaveProperty('profileId');
+ expect(purchaseResult.profile).toHaveProperty('subscriptions');
+ expect(purchaseResult.profile).toHaveProperty('accessLevels');
+ }
+
+ // Platform-independent product fields
+ expect(product).toHaveProperty(
+ 'vendorProductId',
+ sample.product.vendor_product_id,
+ );
+ expect(product).toHaveProperty(
+ 'adaptyId',
+ sample.product.adapty_product_id,
+ );
+ expect(product).toHaveProperty(
+ 'localizedTitle',
+ sample.product.localized_title,
+ );
+ expect(product).toHaveProperty(
+ 'localizedDescription',
+ sample.product.localized_description,
+ );
+ });
+
+ it('should call onPurchaseCompleted handler with user_cancelled result', async () => {
+ const handler: jest.MockedFunction =
+ jest.fn().mockReturnValue(false);
+
+ view.setEventHandlers({ onPurchaseCompleted: handler });
+
+ const viewId = (view as any).id;
+ const sample = PAYWALL_PURCHASE_COMPLETED_CANCELLED;
+
+ emitPaywallPurchaseCompletedEvent(
+ viewId,
+ sample.purchased_result,
+ sample.product,
+ sample.view,
+ );
+
+ expect(handler).toHaveBeenCalledTimes(1);
+ const [purchaseResult] = handler.mock.calls[0]!;
+ expect(purchaseResult).toHaveProperty('type', 'user_cancelled');
+ });
+});
+
+describe('ViewController - onPurchaseFailed event', () => {
+ let adapty: Adapty;
+ let view: ViewController;
+
+ beforeEach(async () => {
+ const result = await createPaywallViewController();
+ adapty = result.adapty;
+ view = result.view;
+ });
+
+ afterEach(() => {
+ cleanupPaywallViewController(view, adapty);
+ });
+
+ it('should call onPurchaseFailed handler when purchase fails', async () => {
+ // NOTE: This test verifies platform-independent fields only.
+ // Platform-specific product fields are tested in platform-specific test files.
+ const handler: jest.MockedFunction = jest
+ .fn()
+ .mockReturnValue(false);
+
+ view.setEventHandlers({ onPurchaseFailed: handler });
+
+ const viewId = (view as any).id;
+ const sample = PAYWALL_PURCHASE_FAILED;
+
+ emitPaywallPurchaseFailedEvent(
+ viewId,
+ sample.error,
+ sample.product,
+ sample.view,
+ );
+
+ expect(handler).toHaveBeenCalledTimes(1);
+ const [error, product] = handler.mock.calls[0]!;
+
+ // Platform-independent error fields
+ expect(error).toBeInstanceOf(AdaptyError);
+ expect(error.adaptyCode).toBe(sample.error.adapty_code);
+ expect(error).toHaveProperty('message');
+
+ // Platform-independent product fields
+ expect(product).toHaveProperty(
+ 'vendorProductId',
+ sample.product.vendor_product_id,
+ );
+ expect(product).toHaveProperty(
+ 'adaptyId',
+ sample.product.adapty_product_id,
+ );
+ expect(product).toHaveProperty(
+ 'localizedTitle',
+ sample.product.localized_title,
+ );
+ expect(product).toHaveProperty(
+ 'localizedDescription',
+ sample.product.localized_description,
+ );
+ });
+});
+
+describe('ViewController - onRestoreStarted event', () => {
+ let adapty: Adapty;
+ let view: ViewController;
+
+ beforeEach(async () => {
+ const result = await createPaywallViewController();
+ adapty = result.adapty;
+ view = result.view;
+ });
+
+ afterEach(() => {
+ cleanupPaywallViewController(view, adapty);
+ });
+
+ it('should call onRestoreStarted handler when restore starts', async () => {
+ const handler: jest.MockedFunction = jest
+ .fn()
+ .mockReturnValue(false);
+
+ view.setEventHandlers({ onRestoreStarted: handler });
+
+ const viewId = (view as any).id;
+ const sample = PAYWALL_RESTORE_STARTED;
+
+ emitPaywallRestoreStartedEvent(viewId, sample.view);
+
+ expect(handler).toHaveBeenCalledTimes(1);
+ expect(handler).toHaveBeenCalledWith();
+ });
+});
+
+describe('ViewController - onRestoreCompleted event', () => {
+ let adapty: Adapty;
+ let view: ViewController;
+
+ beforeEach(async () => {
+ const result = await createPaywallViewController();
+ adapty = result.adapty;
+ view = result.view;
+ });
+
+ afterEach(() => {
+ cleanupPaywallViewController(view, adapty);
+ });
+
+ it('should call onRestoreCompleted handler with profile', async () => {
+ // NOTE: This test verifies platform-independent fields only.
+ // Platform-specific fields (store: "app_store" vs "play_store", transaction ID formats)
+ // are tested in platform-specific test files.
+ const handler: jest.MockedFunction =
+ jest.fn().mockReturnValue(false);
+
+ view.setEventHandlers({ onRestoreCompleted: handler });
+
+ const viewId = (view as any).id;
+ const sample = PAYWALL_RESTORE_COMPLETED_SUCCESS;
+
+ emitPaywallRestoreCompletedEvent(viewId, sample.profile, sample.view);
+
+ expect(handler).toHaveBeenCalledTimes(1);
+ const [profile] = handler.mock.calls[0]!;
+
+ // Platform-independent profile fields
+ expect(profile).toHaveProperty('profileId', sample.profile.profile_id);
+ expect(profile).toHaveProperty('subscriptions');
+ expect(profile).toHaveProperty('accessLevels');
+
+ // Platform-independent subscription fields (structure only, not store-specific values)
+ if (profile.subscriptions) {
+ const subscription = Object.values(profile.subscriptions)[0];
+ expect(subscription).toHaveProperty('vendorProductId');
+ expect(subscription).toHaveProperty('isActive');
+ expect(subscription).toHaveProperty('activatedAt');
+ expect(subscription).toHaveProperty('store'); // Only check presence, not value
+ }
+
+ // Platform-independent access level fields (structure only, not store-specific values)
+ if (profile.accessLevels) {
+ const accessLevel = Object.values(profile.accessLevels)[0];
+ expect(accessLevel).toHaveProperty('id');
+ expect(accessLevel).toHaveProperty('isActive');
+ expect(accessLevel).toHaveProperty('vendorProductId');
+ expect(accessLevel).toHaveProperty('store'); // Only check presence, not value
+ }
+ });
+});
+
+describe('ViewController - onRestoreFailed event', () => {
+ let adapty: Adapty;
+ let view: ViewController;
+
+ beforeEach(async () => {
+ const result = await createPaywallViewController();
+ adapty = result.adapty;
+ view = result.view;
+ });
+
+ afterEach(() => {
+ cleanupPaywallViewController(view, adapty);
+ });
+
+ it('should call onRestoreFailed handler when restore fails', async () => {
+ const handler: jest.MockedFunction = jest
+ .fn()
+ .mockReturnValue(false);
+
+ view.setEventHandlers({ onRestoreFailed: handler });
+
+ const viewId = (view as any).id;
+ const sample = PAYWALL_RESTORE_FAILED;
+
+ emitPaywallRestoreFailedEvent(viewId, sample.error, sample.view);
+
+ expect(handler).toHaveBeenCalledTimes(1);
+ const [error] = handler.mock.calls[0]!;
+ expect(error).toBeInstanceOf(AdaptyError);
+ expect(error.adaptyCode).toBe(sample.error.adapty_code);
+ });
+});
+
+describe('ViewController - onCloseButtonPress event', () => {
+ let adapty: Adapty;
+ let view: ViewController;
+
+ beforeEach(async () => {
+ const result = await createPaywallViewController();
+ adapty = result.adapty;
+ view = result.view;
+ });
+
+ afterEach(() => {
+ cleanupPaywallViewController(view, adapty);
+ });
+
+ it('should call onCloseButtonPress handler when close button is pressed', async () => {
+ const handler: jest.MockedFunction =
+ jest.fn().mockReturnValue(false);
+
+ view.setEventHandlers({ onCloseButtonPress: handler });
+
+ const viewId = (view as any).id;
+ const sample = PAYWALL_USER_ACTION_CLOSE_BUTTON;
+
+ emitPaywallUserActionEvent(viewId, 'close', undefined, sample.view);
+
+ expect(handler).toHaveBeenCalledTimes(1);
+ expect(handler).toHaveBeenCalledWith();
+ });
+});
+
+describe('ViewController - onAndroidSystemBack event', () => {
+ let adapty: Adapty;
+ let view: ViewController;
+
+ beforeEach(async () => {
+ const result = await createPaywallViewController();
+ adapty = result.adapty;
+ view = result.view;
+ });
+
+ afterEach(() => {
+ cleanupPaywallViewController(view, adapty);
+ });
+
+ it('should call onAndroidSystemBack handler when system back is pressed', async () => {
+ const handler: jest.MockedFunction =
+ jest.fn().mockReturnValue(false);
+
+ view.setEventHandlers({ onAndroidSystemBack: handler });
+
+ const viewId = (view as any).id;
+ const sample = PAYWALL_USER_ACTION_SYSTEM_BACK;
+
+ emitPaywallUserActionEvent(viewId, 'system_back', undefined, sample.view);
+
+ expect(handler).toHaveBeenCalledTimes(1);
+ expect(handler).toHaveBeenCalledWith();
+ });
+});
+
+describe('ViewController - onUrlPress event', () => {
+ let adapty: Adapty;
+ let view: ViewController;
+
+ beforeEach(async () => {
+ const result = await createPaywallViewController();
+ adapty = result.adapty;
+ view = result.view;
+ });
+
+ afterEach(() => {
+ cleanupPaywallViewController(view, adapty);
+ });
+
+ it('should call onUrlPress handler with URL when URL is pressed', async () => {
+ const handler: jest.MockedFunction = jest
+ .fn()
+ .mockReturnValue(false);
+
+ view.setEventHandlers({ onUrlPress: handler });
+
+ const viewId = (view as any).id;
+ const sample = PAYWALL_USER_ACTION_OPEN_URL;
+
+ emitPaywallUserActionEvent(
+ viewId,
+ 'open_url',
+ sample.action.value,
+ sample.view,
+ );
+
+ expect(handler).toHaveBeenCalledTimes(1);
+ expect(handler).toHaveBeenCalledWith(sample.action.value);
+ });
+});
+
+describe('ViewController - onCustomAction event', () => {
+ let adapty: Adapty;
+ let view: ViewController;
+
+ beforeEach(async () => {
+ const result = await createPaywallViewController();
+ adapty = result.adapty;
+ view = result.view;
+ });
+
+ afterEach(() => {
+ cleanupPaywallViewController(view, adapty);
+ });
+
+ it('should call onCustomAction handler with action value', async () => {
+ const handler: jest.MockedFunction = jest
+ .fn()
+ .mockReturnValue(false);
+
+ view.setEventHandlers({ onCustomAction: handler });
+
+ const viewId = (view as any).id;
+ const sample = PAYWALL_USER_ACTION_CLOSE; // Reuse sample
+
+ const customValue = 'my_custom_action';
+ emitPaywallUserActionEvent(viewId, 'custom', customValue, sample.view);
+
+ expect(handler).toHaveBeenCalledTimes(1);
+ expect(handler).toHaveBeenCalledWith(customValue);
+ });
+});
+
+describe('ViewController - onPaywallShown event', () => {
+ let adapty: Adapty;
+ let view: ViewController;
+
+ beforeEach(async () => {
+ const result = await createPaywallViewController();
+ adapty = result.adapty;
+ view = result.view;
+ });
+
+ afterEach(() => {
+ cleanupPaywallViewController(view, adapty);
+ });
+
+ it('should call onPaywallShown handler when paywall appears', async () => {
+ const handler: jest.MockedFunction = jest
+ .fn()
+ .mockReturnValue(false);
+
+ view.setEventHandlers({ onPaywallShown: handler });
+
+ const viewId = (view as any).id;
+ const sample = PAYWALL_VIEW_APPEARED;
+
+ emitPaywallViewAppearedEvent(viewId, sample.view);
+
+ expect(handler).toHaveBeenCalledTimes(1);
+ expect(handler).toHaveBeenCalledWith();
+ });
+});
+
+describe('ViewController - onPaywallClosed event', () => {
+ let adapty: Adapty;
+ let view: ViewController;
+
+ beforeEach(async () => {
+ const result = await createPaywallViewController();
+ adapty = result.adapty;
+ view = result.view;
+ });
+
+ afterEach(() => {
+ cleanupPaywallViewController(view, adapty);
+ });
+
+ it('should call onPaywallClosed handler when paywall disappears', async () => {
+ const handler: jest.MockedFunction = jest
+ .fn()
+ .mockReturnValue(false);
+
+ view.setEventHandlers({ onPaywallClosed: handler });
+
+ const viewId = (view as any).id;
+ const sample = PAYWALL_VIEW_DISAPPEARED;
+
+ emitPaywallViewDisappearedEvent(viewId, sample.view);
+
+ expect(handler).toHaveBeenCalledTimes(1);
+ expect(handler).toHaveBeenCalledWith();
+ });
+});
+
+describe('ViewController - onRenderingFailed event', () => {
+ let adapty: Adapty;
+ let view: ViewController;
+
+ beforeEach(async () => {
+ const result = await createPaywallViewController();
+ adapty = result.adapty;
+ view = result.view;
+ });
+
+ afterEach(() => {
+ cleanupPaywallViewController(view, adapty);
+ });
+
+ it('should call onRenderingFailed handler when rendering fails', async () => {
+ const handler: jest.MockedFunction =
+ jest.fn().mockReturnValue(false);
+
+ view.setEventHandlers({ onRenderingFailed: handler });
+
+ const viewId = (view as any).id;
+ const sample = PAYWALL_RENDERING_FAILED;
+
+ emitPaywallRenderingFailedEvent(viewId, sample.error, sample.view);
+
+ expect(handler).toHaveBeenCalledTimes(1);
+ const [error] = handler.mock.calls[0]!;
+ expect(error).toBeInstanceOf(AdaptyError);
+ expect(error.adaptyCode).toBe(sample.error.adapty_code);
+ });
+});
+
+describe('ViewController - onLoadingProductsFailed event', () => {
+ let adapty: Adapty;
+ let view: ViewController;
+
+ beforeEach(async () => {
+ const result = await createPaywallViewController();
+ adapty = result.adapty;
+ view = result.view;
+ });
+
+ afterEach(() => {
+ cleanupPaywallViewController(view, adapty);
+ });
+
+ it('should call onLoadingProductsFailed handler when loading products fails', async () => {
+ const handler: jest.MockedFunction<
+ EventHandlers['onLoadingProductsFailed']
+ > = jest.fn().mockReturnValue(false);
+
+ view.setEventHandlers({ onLoadingProductsFailed: handler });
+
+ const viewId = (view as any).id;
+ const sample = PAYWALL_LOADING_PRODUCTS_FAILED;
+
+ emitPaywallLoadingProductsFailedEvent(viewId, sample.error, sample.view);
+
+ expect(handler).toHaveBeenCalledTimes(1);
+ const [error] = handler.mock.calls[0]!;
+ expect(error).toBeInstanceOf(AdaptyError);
+ expect(error.adaptyCode).toBe(sample.error.adapty_code);
+ });
+});
+
+describe('ViewController - onWebPaymentNavigationFinished event', () => {
+ let adapty: Adapty;
+ let view: ViewController;
+
+ beforeEach(async () => {
+ const result = await createPaywallViewController();
+ adapty = result.adapty;
+ view = result.view;
+ });
+
+ afterEach(() => {
+ cleanupPaywallViewController(view, adapty);
+ });
+
+ it('should call onWebPaymentNavigationFinished handler with product', async () => {
+ // NOTE: This test verifies platform-independent fields only.
+ // Platform-specific product fields are tested in platform-specific test files.
+ const handler: jest.MockedFunction<
+ EventHandlers['onWebPaymentNavigationFinished']
+ > = jest.fn().mockReturnValue(false);
+
+ view.setEventHandlers({ onWebPaymentNavigationFinished: handler });
+
+ const viewId = (view as any).id;
+ const sample = PAYWALL_WEB_PAYMENT_NAVIGATION_FINISHED;
+
+ emitPaywallWebPaymentNavigationFinishedEvent(
+ viewId,
+ sample.product,
+ undefined,
+ sample.view,
+ );
+
+ expect(handler).toHaveBeenCalledTimes(1);
+ const [product, error] = handler.mock.calls[0]!;
+
+ // Platform-independent product fields
+ expect(product).toBeDefined();
+ if (product) {
+ expect(product).toHaveProperty(
+ 'vendorProductId',
+ sample.product.vendor_product_id,
+ );
+ expect(product).toHaveProperty(
+ 'adaptyId',
+ sample.product.adapty_product_id,
+ );
+ expect(product).toHaveProperty(
+ 'localizedTitle',
+ sample.product.localized_title,
+ );
+ expect(product).toHaveProperty(
+ 'localizedDescription',
+ sample.product.localized_description,
+ );
+ expect(product).toHaveProperty('price');
+ if (product.price) {
+ expect(product.price).toHaveProperty(
+ 'amount',
+ sample.product.price.amount,
+ );
+ expect(product.price).toHaveProperty(
+ 'currencyCode',
+ sample.product.price.currency_code,
+ );
+ }
+
+ // Platform-independent subscription fields
+ if (product.subscription) {
+ expect(product.subscription).toHaveProperty('subscriptionPeriod');
+ expect(product.subscription.subscriptionPeriod).toHaveProperty(
+ 'unit',
+ sample.product.subscription.period.unit,
+ );
+ expect(product.subscription.subscriptionPeriod).toHaveProperty(
+ 'numberOfUnits',
+ sample.product.subscription.period.number_of_units,
+ );
+ }
+ }
+
+ expect(error).toBeUndefined();
+ });
+
+ it('should call onWebPaymentNavigationFinished handler with error', async () => {
+ const handler: jest.MockedFunction<
+ EventHandlers['onWebPaymentNavigationFinished']
+ > = jest.fn().mockReturnValue(false);
+
+ view.setEventHandlers({ onWebPaymentNavigationFinished: handler });
+
+ const viewId = (view as any).id;
+ const sample = PAYWALL_WEB_PAYMENT_NAVIGATION_FINISHED;
+
+ const mockError = { adapty_code: 999, message: 'Web payment error' };
+ emitPaywallWebPaymentNavigationFinishedEvent(
+ viewId,
+ undefined,
+ mockError,
+ sample.view,
+ );
+
+ expect(handler).toHaveBeenCalledTimes(1);
+ const [product, error] = handler.mock.calls[0]!;
+ expect(product).toBeUndefined();
+ expect(error).toBeInstanceOf(AdaptyError);
+ });
+});
+
+describe('ViewController - event viewId filtering', () => {
+ let adapty: Adapty;
+ let view: ViewController;
+
+ beforeEach(async () => {
+ const result = await createPaywallViewController();
+ adapty = result.adapty;
+ view = result.view;
+ });
+
+ afterEach(() => {
+ cleanupPaywallViewController(view, adapty);
+ });
+
+ it('should ignore events when viewId does not match', async () => {
+ // Create mock handlers for all event types
+ const onProductSelectedHandler = jest.fn().mockReturnValue(false);
+ const onPurchaseStartedHandler = jest.fn().mockReturnValue(false);
+ const onPurchaseCompletedHandler = jest.fn().mockReturnValue(false);
+ const onRestoreStartedHandler = jest.fn().mockReturnValue(false);
+ const onCloseHandler = jest.fn().mockReturnValue(false);
+ const onPaywallShownHandler = jest.fn().mockReturnValue(false);
+
+ // Register all handlers
+ view.setEventHandlers({
+ onProductSelected: onProductSelectedHandler,
+ onPurchaseStarted: onPurchaseStartedHandler,
+ onPurchaseCompleted: onPurchaseCompletedHandler,
+ onRestoreStarted: onRestoreStartedHandler,
+ onCloseButtonPress: onCloseHandler,
+ onPaywallShown: onPaywallShownHandler,
+ });
+
+ // Use a different viewId
+ const wrongViewId = 'wrong_view_id_12345';
+ const sample = PAYWALL_PRODUCT_SELECTED_YEARLY;
+
+ // Emit all event types with wrong viewId
+ emitPaywallProductSelectedEvent(
+ wrongViewId,
+ sample.product_id,
+ sample.view,
+ );
+ emitPaywallPurchaseStartedEvent(
+ wrongViewId,
+ PAYWALL_PURCHASE_STARTED.product,
+ sample.view,
+ );
+ emitPaywallPurchaseCompletedEvent(
+ wrongViewId,
+ PAYWALL_PURCHASE_COMPLETED_SUCCESS.purchased_result,
+ PAYWALL_PURCHASE_COMPLETED_SUCCESS.product,
+ sample.view,
+ );
+ emitPaywallRestoreStartedEvent(wrongViewId, sample.view);
+ emitPaywallUserActionEvent(wrongViewId, 'close', undefined, sample.view);
+ emitPaywallViewAppearedEvent(wrongViewId, sample.view);
+
+ // Verify that NONE of the handlers were called
+ expect(onProductSelectedHandler).not.toHaveBeenCalled();
+ expect(onPurchaseStartedHandler).not.toHaveBeenCalled();
+ expect(onPurchaseCompletedHandler).not.toHaveBeenCalled();
+ expect(onRestoreStartedHandler).not.toHaveBeenCalled();
+ expect(onCloseHandler).not.toHaveBeenCalled();
+ expect(onPaywallShownHandler).not.toHaveBeenCalled();
+ });
+});
+
+describe('ViewController - multiple views isolation', () => {
+ let adapty: Adapty;
+ let view: ViewController;
+
+ beforeEach(async () => {
+ const result = await createPaywallViewController();
+ adapty = result.adapty;
+ view = result.view;
+ });
+
+ afterEach(() => {
+ cleanupPaywallViewController(view, adapty);
+ });
+
+ it('should isolate event handlers between view instances', async () => {
+ // Create a second view using the SAME adapty instance
+ const { paywall: paywall2 } = await createPaywallViewController();
+ const view2 = await (view.constructor as any).create(paywall2, {});
+
+ try {
+ const viewId1 = (view as any).id;
+ const viewId2 = (view2 as any).id;
+
+ const sample = PAYWALL_PRODUCT_SELECTED_YEARLY;
+
+ // Create handlers for both views
+ const handler1 = jest.fn().mockReturnValue(false);
+ const handler2 = jest.fn().mockReturnValue(false);
+
+ // Register handlers on both views
+ const unsubscribe1 = view.setEventHandlers({
+ onProductSelected: handler1,
+ });
+ view2.setEventHandlers({ onProductSelected: handler2 });
+
+ // Emit events to both views - both should receive
+ emitPaywallProductSelectedEvent(viewId1, sample.product_id, sample.view);
+ emitPaywallProductSelectedEvent(viewId2, sample.product_id, sample.view);
+
+ expect(handler1).toHaveBeenCalledTimes(1);
+ expect(handler2).toHaveBeenCalledTimes(1);
+
+ // Clear mocks
+ handler1.mockClear();
+ handler2.mockClear();
+
+ // Unsubscribe first view
+ unsubscribe1();
+
+ // Emit events again
+ emitPaywallProductSelectedEvent(viewId1, sample.product_id, sample.view);
+ emitPaywallProductSelectedEvent(viewId2, sample.product_id, sample.view);
+
+ // First view should not receive (unsubscribed)
+ expect(handler1).not.toHaveBeenCalled();
+ // Second view should still receive (not affected by first view's unsubscribe)
+ expect(handler2).toHaveBeenCalledTimes(1);
+ } finally {
+ // Cleanup second view
+ // view2 cleanup happens through adapty.removeAllListeners
+ }
+ });
+});
+
+describe('ViewController - unsubscribe functionality', () => {
+ let adapty: Adapty;
+ let view: ViewController;
+
+ beforeEach(async () => {
+ const result = await createPaywallViewController();
+ adapty = result.adapty;
+ view = result.view;
+ });
+
+ afterEach(() => {
+ cleanupPaywallViewController(view, adapty);
+ });
+
+ it('should unsubscribe all handlers using returned unsubscribe function', async () => {
+ const viewId = (view as any).id;
+ const sample = PAYWALL_PRODUCT_SELECTED_YEARLY;
+
+ // Set up multiple handlers
+ const onProductSelectedHandler = jest.fn().mockReturnValue(false);
+ const onPurchaseStartedHandler = jest.fn().mockReturnValue(false);
+ const onCloseHandler = jest.fn().mockReturnValue(false);
+
+ const unsubscribe = view.setEventHandlers({
+ onProductSelected: onProductSelectedHandler,
+ onPurchaseStarted: onPurchaseStartedHandler,
+ onCloseButtonPress: onCloseHandler,
+ });
+
+ // Emit events BEFORE unsubscribe - handlers should be called
+ emitPaywallProductSelectedEvent(viewId, sample.product_id, sample.view);
+ emitPaywallPurchaseStartedEvent(
+ viewId,
+ PAYWALL_PURCHASE_STARTED.product,
+ sample.view,
+ );
+ emitPaywallUserActionEvent(viewId, 'close', undefined, sample.view);
+
+ expect(onProductSelectedHandler).toHaveBeenCalledTimes(1);
+ expect(onPurchaseStartedHandler).toHaveBeenCalledTimes(1);
+ expect(onCloseHandler).toHaveBeenCalledTimes(1);
+
+ // Clear mocks
+ onProductSelectedHandler.mockClear();
+ onPurchaseStartedHandler.mockClear();
+ onCloseHandler.mockClear();
+
+ // Call unsubscribe
+ unsubscribe();
+ // Idempotency: second call should not throw
+ expect(() => unsubscribe()).not.toThrow();
+
+ // Emit events AFTER unsubscribe - handlers should NOT be called
+ emitPaywallProductSelectedEvent(viewId, sample.product_id, sample.view);
+ emitPaywallPurchaseStartedEvent(
+ viewId,
+ PAYWALL_PURCHASE_STARTED.product,
+ sample.view,
+ );
+ emitPaywallUserActionEvent(viewId, 'close', undefined, sample.view);
+
+ expect(onProductSelectedHandler).not.toHaveBeenCalled();
+ expect(onPurchaseStartedHandler).not.toHaveBeenCalled();
+ expect(onCloseHandler).not.toHaveBeenCalled();
+ });
+
+ it('should allow re-subscribing after unsubscribe', async () => {
+ const viewId = (view as any).id;
+ const sample = PAYWALL_PRODUCT_SELECTED_YEARLY;
+
+ // First subscription
+ const onProductSelectedHandler1 = jest.fn().mockReturnValue(false);
+ const unsubscribe1 = view.setEventHandlers({
+ onProductSelected: onProductSelectedHandler1,
+ });
+
+ // Emit event - should be handled
+ emitPaywallProductSelectedEvent(viewId, sample.product_id, sample.view);
+ expect(onProductSelectedHandler1).toHaveBeenCalledTimes(1);
+
+ // Unsubscribe
+ unsubscribe1();
+ onProductSelectedHandler1.mockClear();
+
+ // Emit event - should NOT be handled
+ emitPaywallProductSelectedEvent(viewId, sample.product_id, sample.view);
+ expect(onProductSelectedHandler1).not.toHaveBeenCalled();
+
+ // Re-subscribe with new handler
+ const onProductSelectedHandler2 = jest.fn().mockReturnValue(false);
+ view.setEventHandlers({
+ onProductSelected: onProductSelectedHandler2,
+ });
+
+ // Emit event - new handler should be called
+ emitPaywallProductSelectedEvent(viewId, sample.product_id, sample.view);
+ expect(onProductSelectedHandler1).not.toHaveBeenCalled(); // Old handler still not called
+ expect(onProductSelectedHandler2).toHaveBeenCalledTimes(1); // New handler called
+ });
+});
+
+describe('ViewController - dismiss on handler return value', () => {
+ let adapty: Adapty;
+ let view: ViewController;
+
+ beforeEach(async () => {
+ const result = await createPaywallViewController();
+ adapty = result.adapty;
+ view = result.view;
+ });
+
+ afterEach(() => {
+ cleanupPaywallViewController(view, adapty);
+ });
+
+ it('should call dismiss when any handler returns true', async () => {
+ const viewId = (view as any).id;
+ const sample = PAYWALL_PRODUCT_SELECTED_YEARLY;
+
+ // Test onProductSelected returning true
+ const onProductSelectedHandler = jest.fn().mockReturnValue(true);
+ const dismissSpy = jest.spyOn(view, 'dismiss').mockResolvedValue();
+
+ view.setEventHandlers({ onProductSelected: onProductSelectedHandler });
+ emitPaywallProductSelectedEvent(viewId, sample.product_id, sample.view);
+ expect(onProductSelectedHandler).toHaveBeenCalledTimes(1);
+ expect(dismissSpy).toHaveBeenCalledTimes(1);
+
+ // Reset
+ onProductSelectedHandler.mockClear();
+ dismissSpy.mockClear();
+
+ // Test onCloseButtonPress returning true
+ const onCloseHandler = jest.fn().mockReturnValue(true);
+ view.setEventHandlers({ onCloseButtonPress: onCloseHandler });
+ emitPaywallUserActionEvent(viewId, 'close', undefined, sample.view);
+ expect(onCloseHandler).toHaveBeenCalledTimes(1);
+ expect(dismissSpy).toHaveBeenCalledTimes(1);
+
+ // Reset
+ onCloseHandler.mockClear();
+ dismissSpy.mockClear();
+
+ // Test onPurchaseCompleted returning true
+ const onPurchaseCompletedHandler = jest.fn().mockReturnValue(true);
+ view.setEventHandlers({ onPurchaseCompleted: onPurchaseCompletedHandler });
+ emitPaywallPurchaseCompletedEvent(
+ viewId,
+ PAYWALL_PURCHASE_COMPLETED_SUCCESS.purchased_result,
+ PAYWALL_PURCHASE_COMPLETED_SUCCESS.product,
+ sample.view,
+ );
+ expect(onPurchaseCompletedHandler).toHaveBeenCalledTimes(1);
+ expect(dismissSpy).toHaveBeenCalledTimes(1);
+
+ dismissSpy.mockRestore();
+ });
+
+ it('should NOT call dismiss when handlers return false', async () => {
+ const viewId = (view as any).id;
+ const sample = PAYWALL_PRODUCT_SELECTED_YEARLY;
+ const dismissSpy = jest.spyOn(view, 'dismiss').mockResolvedValue();
+
+ // Test onProductSelected returning false
+ const onProductSelectedHandler = jest.fn().mockReturnValue(false);
+ view.setEventHandlers({ onProductSelected: onProductSelectedHandler });
+ emitPaywallProductSelectedEvent(viewId, sample.product_id, sample.view);
+ expect(onProductSelectedHandler).toHaveBeenCalledTimes(1);
+ expect(dismissSpy).not.toHaveBeenCalled();
+
+ onProductSelectedHandler.mockClear();
+
+ // Test onCloseButtonPress returning false
+ const onCloseHandler = jest.fn().mockReturnValue(false);
+ view.setEventHandlers({ onCloseButtonPress: onCloseHandler });
+ emitPaywallUserActionEvent(viewId, 'close', undefined, sample.view);
+ expect(onCloseHandler).toHaveBeenCalledTimes(1);
+ expect(dismissSpy).not.toHaveBeenCalled();
+
+ dismissSpy.mockRestore();
+ });
+
+ it('should NOT call dismiss when handlers return undefined', async () => {
+ const viewId = (view as any).id;
+ const sample = PAYWALL_PRODUCT_SELECTED_YEARLY;
+ const dismissSpy = jest.spyOn(view, 'dismiss').mockResolvedValue();
+
+ // Test onProductSelected returning undefined
+ const onProductSelectedHandler = jest.fn().mockReturnValue(undefined);
+ view.setEventHandlers({ onProductSelected: onProductSelectedHandler });
+ emitPaywallProductSelectedEvent(viewId, sample.product_id, sample.view);
+ expect(onProductSelectedHandler).toHaveBeenCalledTimes(1);
+ expect(dismissSpy).not.toHaveBeenCalled();
+
+ onProductSelectedHandler.mockClear();
+
+ // Test onRestoreStarted returning undefined
+ const onRestoreStartedHandler = jest.fn().mockReturnValue(undefined);
+ view.setEventHandlers({ onRestoreStarted: onRestoreStartedHandler });
+ emitPaywallRestoreStartedEvent(viewId, sample.view);
+ expect(onRestoreStartedHandler).toHaveBeenCalledTimes(1);
+ expect(dismissSpy).not.toHaveBeenCalled();
+
+ dismissSpy.mockRestore();
+ });
+});
+
+describe('ViewController - dismiss cleanup', () => {
+ let adapty: Adapty;
+ let view: ViewController;
+
+ beforeEach(async () => {
+ const result = await createPaywallViewController();
+ adapty = result.adapty;
+ view = result.view;
+ });
+
+ afterEach(() => {
+ cleanupPaywallViewController(view, adapty);
+ });
+
+ it('should unsubscribe all event listeners after dismiss', async () => {
+ const viewId = (view as any).id;
+ const sample = PAYWALL_PRODUCT_SELECTED_YEARLY;
+
+ // Set up handlers for multiple event types
+ const onProductSelectedHandler = jest.fn().mockReturnValue(false);
+ const onPurchaseStartedHandler = jest.fn().mockReturnValue(false);
+ const onCloseHandler = jest.fn().mockReturnValue(false);
+ const onRestoreStartedHandler = jest.fn().mockReturnValue(false);
+
+ view.setEventHandlers({
+ onProductSelected: onProductSelectedHandler,
+ onPurchaseStarted: onPurchaseStartedHandler,
+ onCloseButtonPress: onCloseHandler,
+ onRestoreStarted: onRestoreStartedHandler,
+ });
+
+ // Emit events BEFORE dismiss - handlers should be called
+ emitPaywallProductSelectedEvent(viewId, sample.product_id, sample.view);
+ emitPaywallPurchaseStartedEvent(
+ viewId,
+ PAYWALL_PURCHASE_STARTED.product,
+ sample.view,
+ );
+
+ expect(onProductSelectedHandler).toHaveBeenCalledTimes(1);
+ expect(onPurchaseStartedHandler).toHaveBeenCalledTimes(1);
+
+ // Clear mock call history
+ onProductSelectedHandler.mockClear();
+ onPurchaseStartedHandler.mockClear();
+ onCloseHandler.mockClear();
+ onRestoreStartedHandler.mockClear();
+
+ // Call dismiss
+ await view.dismiss();
+
+ // Emit onPaywallClosed event - this triggers cleanup via internal handler
+ emitPaywallViewDisappearedEvent(viewId, sample.view);
+
+ // Emit events AFTER cleanup - handlers should NOT be called
+ emitPaywallProductSelectedEvent(viewId, sample.product_id, sample.view);
+ emitPaywallPurchaseStartedEvent(
+ viewId,
+ PAYWALL_PURCHASE_STARTED.product,
+ sample.view,
+ );
+ emitPaywallUserActionEvent(viewId, 'close', undefined, sample.view);
+ emitPaywallRestoreStartedEvent(viewId, sample.view);
+
+ // Verify that NONE of the handlers were called after cleanup
+ expect(onProductSelectedHandler).not.toHaveBeenCalled();
+ expect(onPurchaseStartedHandler).not.toHaveBeenCalled();
+ expect(onCloseHandler).not.toHaveBeenCalled();
+ expect(onRestoreStartedHandler).not.toHaveBeenCalled();
+ });
+
+ it('should not throw error when dismiss is called without any handlers set', async () => {
+ // Don't set any custom handlers, only default ones exist
+ await expect(view.dismiss()).resolves.not.toThrow();
+ });
+
+ it('should not throw error when dismiss is called multiple times', async () => {
+ await view.dismiss();
+ // Second dismiss should not throw
+ await expect(view.dismiss()).resolves.not.toThrow();
+ });
+});
+
+describe('ViewController - default event handlers', () => {
+ let adapty: Adapty;
+ let view: ViewController;
+
+ beforeEach(async () => {
+ const result = await createPaywallViewController();
+ adapty = result.adapty;
+ view = result.view;
+ });
+
+ afterEach(() => {
+ cleanupPaywallViewController(view, adapty);
+ });
+
+ it('should auto-dismiss paywall when onCloseButtonPress event is emitted with default handler', async () => {
+ const viewId = (view as any).id;
+ const sample = PAYWALL_USER_ACTION_CLOSE;
+
+ // Spy on dismiss method to verify it's called
+ const dismissSpy = jest.spyOn(view, 'dismiss').mockResolvedValue();
+
+ // Emit close event WITHOUT setting custom handler
+ // Default handler (onCloseButtonPress: () => true) should be active from create()
+ emitPaywallUserActionEvent(viewId, 'close', undefined, sample.view);
+
+ // Verify dismiss was called due to default handler returning true
+ expect(dismissSpy).toHaveBeenCalledTimes(1);
+
+ dismissSpy.mockRestore();
+ });
+
+ it('should auto-dismiss paywall when onAndroidSystemBack event is emitted with default handler', async () => {
+ const viewId = (view as any).id;
+ const sample = PAYWALL_USER_ACTION_SYSTEM_BACK;
+
+ // Spy on dismiss method to verify it's called
+ const dismissSpy = jest.spyOn(view, 'dismiss').mockResolvedValue();
+
+ // Emit system_back event WITHOUT setting custom handler
+ // Default handler (onAndroidSystemBack: () => true) should be active from create()
+ emitPaywallUserActionEvent(viewId, 'system_back', undefined, sample.view);
+
+ // Verify dismiss was called due to default handler returning true
+ expect(dismissSpy).toHaveBeenCalledTimes(1);
+
+ dismissSpy.mockRestore();
+ });
+
+ it('should auto-dismiss paywall when onRenderingFailed event is emitted with default handler', async () => {
+ const viewId = (view as any).id;
+ const sample = PAYWALL_RENDERING_FAILED;
+
+ // Spy on dismiss method to verify it's called
+ const dismissSpy = jest.spyOn(view, 'dismiss').mockResolvedValue();
+
+ // Emit rendering_failed event WITHOUT setting custom handler
+ // Default handler (onRenderingFailed: () => true) should be active from create()
+ emitPaywallRenderingFailedEvent(viewId, sample.error, sample.view);
+
+ // Verify dismiss was called due to default handler returning true
+ expect(dismissSpy).toHaveBeenCalledTimes(1);
+
+ dismissSpy.mockRestore();
+ });
+
+ it('should allow overriding default onCloseButtonPress handler', async () => {
+ const viewId = (view as any).id;
+ const sample = PAYWALL_USER_ACTION_CLOSE;
+
+ const dismissSpy = jest.spyOn(view, 'dismiss').mockResolvedValue();
+
+ // Override default handler with one that returns false
+ const customHandler = jest.fn().mockReturnValue(false);
+ view.setEventHandlers({ onCloseButtonPress: customHandler });
+
+ // Emit close event
+ emitPaywallUserActionEvent(viewId, 'close', undefined, sample.view);
+
+ // Custom handler should be called
+ expect(customHandler).toHaveBeenCalledTimes(1);
+
+ // Dismiss should NOT be called because custom handler returned false
+ expect(dismissSpy).not.toHaveBeenCalled();
+
+ dismissSpy.mockRestore();
+ });
+
+ it('should allow overriding default onAndroidSystemBack handler', async () => {
+ const viewId = (view as any).id;
+ const sample = PAYWALL_USER_ACTION_SYSTEM_BACK;
+
+ const dismissSpy = jest.spyOn(view, 'dismiss').mockResolvedValue();
+
+ // Override default handler with one that returns false
+ const customHandler = jest.fn().mockReturnValue(false);
+ view.setEventHandlers({ onAndroidSystemBack: customHandler });
+
+ // Emit system_back event
+ emitPaywallUserActionEvent(viewId, 'system_back', undefined, sample.view);
+
+ // Custom handler should be called
+ expect(customHandler).toHaveBeenCalledTimes(1);
+
+ // Dismiss should NOT be called because custom handler returned false
+ expect(dismissSpy).not.toHaveBeenCalled();
+
+ dismissSpy.mockRestore();
+ });
+
+ it('should allow overriding default onRenderingFailed handler', async () => {
+ const viewId = (view as any).id;
+ const sample = PAYWALL_RENDERING_FAILED;
+
+ const dismissSpy = jest.spyOn(view, 'dismiss').mockResolvedValue();
+
+ // Override default handler with one that returns false
+ const customHandler = jest.fn().mockReturnValue(false);
+ view.setEventHandlers({ onRenderingFailed: customHandler });
+
+ // Emit rendering_failed event
+ emitPaywallRenderingFailedEvent(viewId, sample.error, sample.view);
+
+ // Custom handler should be called
+ expect(customHandler).toHaveBeenCalledTimes(1);
+
+ // Dismiss should NOT be called because custom handler returned false
+ expect(dismissSpy).not.toHaveBeenCalled();
+
+ dismissSpy.mockRestore();
+ });
+});
+
+describe('ViewController - setEventHandlers merge behavior', () => {
+ let adapty: Adapty;
+ let view: ViewController;
+
+ beforeEach(async () => {
+ const result = await createPaywallViewController();
+ adapty = result.adapty;
+ view = result.view;
+ });
+
+ afterEach(() => {
+ cleanupPaywallViewController(view, adapty);
+ });
+
+ it('should preserve previously set handlers when adding new ones', async () => {
+ const viewId = (view as any).id;
+ const sample = PAYWALL_PRODUCT_SELECTED_YEARLY;
+
+ // Set first handler
+ const onProductSelectedHandler = jest.fn().mockReturnValue(false);
+ view.setEventHandlers({ onProductSelected: onProductSelectedHandler });
+
+ // Set second handler - onProductSelected should still be active
+ const onPurchaseStartedHandler = jest.fn().mockReturnValue(false);
+ view.setEventHandlers({ onPurchaseStarted: onPurchaseStartedHandler });
+
+ // Emit events for both handlers
+ emitPaywallProductSelectedEvent(viewId, sample.product_id, sample.view);
+ emitPaywallPurchaseStartedEvent(
+ viewId,
+ PAYWALL_PURCHASE_STARTED.product,
+ sample.view,
+ );
+
+ // Both handlers should have been called
+ expect(onProductSelectedHandler).toHaveBeenCalledTimes(1);
+ expect(onProductSelectedHandler).toHaveBeenCalledWith(sample.product_id);
+ expect(onPurchaseStartedHandler).toHaveBeenCalledTimes(1);
+ });
+
+ it('should replace handler when setting same event type again', async () => {
+ const viewId = (view as any).id;
+ const sample = PAYWALL_PRODUCT_SELECTED_YEARLY;
+
+ // Set first onProductSelected handler
+ const onProductSelectedHandler1 = jest.fn().mockReturnValue(false);
+ view.setEventHandlers({ onProductSelected: onProductSelectedHandler1 });
+
+ // Replace with second onProductSelected handler
+ const onProductSelectedHandler2 = jest.fn().mockReturnValue(false);
+ view.setEventHandlers({ onProductSelected: onProductSelectedHandler2 });
+
+ // Emit product selected event
+ emitPaywallProductSelectedEvent(viewId, sample.product_id, sample.view);
+
+ // Only the second handler should be called
+ expect(onProductSelectedHandler1).not.toHaveBeenCalled();
+ expect(onProductSelectedHandler2).toHaveBeenCalledTimes(1);
+ expect(onProductSelectedHandler2).toHaveBeenCalledWith(sample.product_id);
+ });
+
+ it('should preserve multiple handlers across successive setEventHandlers calls', async () => {
+ const viewId = (view as any).id;
+ const sample = PAYWALL_PRODUCT_SELECTED_YEARLY;
+
+ // Set handlers one by one
+ const onProductSelectedHandler = jest.fn().mockReturnValue(false);
+ view.setEventHandlers({ onProductSelected: onProductSelectedHandler });
+
+ const onPurchaseStartedHandler = jest.fn().mockReturnValue(false);
+ view.setEventHandlers({ onPurchaseStarted: onPurchaseStartedHandler });
+
+ const onRestoreStartedHandler = jest.fn().mockReturnValue(false);
+ view.setEventHandlers({ onRestoreStarted: onRestoreStartedHandler });
+
+ const onCloseHandler = jest.fn().mockReturnValue(false);
+ view.setEventHandlers({ onCloseButtonPress: onCloseHandler });
+
+ // Emit all events
+ emitPaywallProductSelectedEvent(viewId, sample.product_id, sample.view);
+ emitPaywallPurchaseStartedEvent(
+ viewId,
+ PAYWALL_PURCHASE_STARTED.product,
+ sample.view,
+ );
+ emitPaywallRestoreStartedEvent(viewId, sample.view);
+ emitPaywallUserActionEvent(viewId, 'close', undefined, sample.view);
+
+ // All handlers should have been called
+ expect(onProductSelectedHandler).toHaveBeenCalledTimes(1);
+ expect(onPurchaseStartedHandler).toHaveBeenCalledTimes(1);
+ expect(onRestoreStartedHandler).toHaveBeenCalledTimes(1);
+ expect(onCloseHandler).toHaveBeenCalledTimes(1);
+ });
+});
+
+describe('ViewController - onPaywallClosed after user action close', () => {
+ let adapty: Adapty;
+ let view: ViewController;
+
+ beforeEach(async () => {
+ const result = await createPaywallViewController();
+ adapty = result.adapty;
+ view = result.view;
+ });
+
+ afterEach(() => {
+ cleanupPaywallViewController(view, adapty);
+ });
+
+ it('should call onPaywallClosed handler even after close button triggers dismiss', async () => {
+ const viewId = (view as any).id;
+ const closeButtonSample = PAYWALL_USER_ACTION_CLOSE_BUTTON;
+ const closedSample = PAYWALL_VIEW_DISAPPEARED;
+
+ // Set up handlers
+ const onCloseButtonPressHandler: jest.MockedFunction<
+ EventHandlers['onCloseButtonPress']
+ > = jest.fn().mockReturnValue(true); // Returns true to trigger dismiss
+ const onPaywallClosedHandler: jest.MockedFunction<
+ EventHandlers['onPaywallClosed']
+ > = jest.fn().mockReturnValue(false);
+
+ // Spy on dismiss WITHOUT mocking - let it execute normally
+ const dismissSpy = jest.spyOn(view, 'dismiss');
+
+ view.setEventHandlers({
+ onCloseButtonPress: onCloseButtonPressHandler,
+ onPaywallClosed: onPaywallClosedHandler,
+ });
+
+ // 1. User presses close button - this should trigger dismiss
+ emitPaywallUserActionEvent(
+ viewId,
+ 'close',
+ undefined,
+ closeButtonSample.view,
+ );
+
+ expect(onCloseButtonPressHandler).toHaveBeenCalledTimes(1);
+ expect(dismissSpy).toHaveBeenCalledTimes(1);
+
+ // 2. Wait for dismiss to complete (including removeAllListeners)
+ await dismissSpy.mock.results[0]?.value;
+
+ // 3. Native code sends onPaywallClosed event after paywall actually closes
+ emitPaywallViewDisappearedEvent(viewId, closedSample.view);
+
+ // VERIFICATION: onPaywallClosed handler should be called
+ // Cleanup happens via internal handler AFTER this client handler executes
+ expect(onPaywallClosedHandler).toHaveBeenCalledTimes(1);
+
+ dismissSpy.mockRestore();
+ });
+});
diff --git a/src/__tests__/integration/ui/setup.utils.ts b/src/__tests__/integration/ui/setup.utils.ts
index d06e1c24..aeb56a2a 100644
--- a/src/__tests__/integration/ui/setup.utils.ts
+++ b/src/__tests__/integration/ui/setup.utils.ts
@@ -1,6 +1,9 @@
import { Adapty } from '@/adapty-handler';
import { createOnboardingView } from '@/ui/create-onboarding-view';
import { OnboardingViewController } from '@/ui/onboarding-view-controller';
+import { createPaywallView } from '@/ui/create-paywall-view';
+import { ViewController } from '@/ui/view-controller';
+import { AdaptyPaywall } from '@/types';
import {
createAdaptyInstance,
cleanupAdapty,
@@ -31,3 +34,30 @@ export function cleanupOnboardingViewController(
void view; // Suppress unused variable warning
cleanupAdapty(adapty);
}
+
+/**
+ * Creates PaywallViewController for testing
+ */
+export async function createPaywallViewController(): Promise<{
+ adapty: Adapty;
+ view: ViewController;
+ paywall: AdaptyPaywall;
+}> {
+ const adapty = await createAdaptyInstance();
+ const paywall = await adapty.getPaywall('test_placement');
+ const view = await createPaywallView(paywall);
+
+ return { adapty, view, paywall };
+}
+
+/**
+ * Cleanup PaywallViewController and Adapty instance
+ */
+export function cleanupPaywallViewController(
+ view: ViewController,
+ adapty: Adapty,
+): void {
+ // View cleanup happens automatically on dismiss
+ void view; // Suppress unused variable warning
+ cleanupAdapty(adapty);
+}
diff --git a/src/coders/adapty-configuration.test.ts b/src/coders/adapty-configuration.test.ts
index f574fe72..63f2327e 100644
--- a/src/coders/adapty-configuration.test.ts
+++ b/src/coders/adapty-configuration.test.ts
@@ -50,6 +50,7 @@ describe('AdaptyConfigurationCoder', () => {
ios: {
idfaCollectionDisabled: true,
appAccountToken: 'token123',
+ clearDataOnBackup: true,
},
android: {
adIdCollectionDisabled: true,
@@ -71,6 +72,7 @@ describe('AdaptyConfigurationCoder', () => {
},
observer_mode: true,
ip_address_collection_disabled: true,
+ clear_data_on_backup: true,
log_level: 'verbose',
server_cluster: 'eu',
backend_proxy_host: 'proxy.example.com',
@@ -184,6 +186,20 @@ describe('AdaptyConfigurationCoder', () => {
expect(result.log_level).toBeUndefined();
});
+ it('should handle clearDataOnBackup parameter conditionally', () => {
+ const paramsWithout = {};
+ const resultWithout = coder.encode(apiKey, paramsWithout);
+ expect(resultWithout.clear_data_on_backup).toBeUndefined();
+
+ const paramsWithFalse = { ios: { clearDataOnBackup: false } };
+ const resultWithFalse = coder.encode(apiKey, paramsWithFalse);
+ expect(resultWithFalse.clear_data_on_backup).toBe(false);
+
+ const paramsWithTrue = { ios: { clearDataOnBackup: true } };
+ const resultWithTrue = coder.encode(apiKey, paramsWithTrue);
+ expect(resultWithTrue.clear_data_on_backup).toBe(true);
+ });
+
it('should prefer params media cache over default', () => {
const params = {
mediaCache: {
diff --git a/src/coders/adapty-configuration.ts b/src/coders/adapty-configuration.ts
index f4192e69..9a399878 100644
--- a/src/coders/adapty-configuration.ts
+++ b/src/coders/adapty-configuration.ts
@@ -56,6 +56,9 @@ export class AdaptyConfigurationCoder {
app_account_token: params.ios.appAccountToken,
};
}
+ if (params.ios?.clearDataOnBackup !== undefined) {
+ config['clear_data_on_backup'] = params.ios.clearDataOnBackup;
+ }
}
if (Platform.OS === 'android') {
diff --git a/src/coders/adapty-ui-create-onboarding-view-params.ts b/src/coders/adapty-ui-create-onboarding-view-params.ts
index bc0417d1..c6817295 100644
--- a/src/coders/adapty-ui-create-onboarding-view-params.ts
+++ b/src/coders/adapty-ui-create-onboarding-view-params.ts
@@ -1,8 +1,10 @@
import { CreateOnboardingViewParamsInput } from '@/ui/types';
+import { Req } from '@/types/schema';
-type Serializable = {
- external_urls_presentation?: string;
-};
+type Serializable = Pick<
+ Req['AdaptyUICreateOnboardingView.Request'],
+ 'external_urls_presentation'
+>;
export class AdaptyUICreateOnboardingViewParamsCoder {
encode(data: CreateOnboardingViewParamsInput): Serializable {
diff --git a/src/coders/parse-paywall.ts b/src/coders/parse-paywall.ts
new file mode 100644
index 00000000..c1a8b59f
--- /dev/null
+++ b/src/coders/parse-paywall.ts
@@ -0,0 +1,224 @@
+import { AdaptyError } from '@/adapty-error';
+import { LogContext } from '../logger';
+import { ErrorConverter } from './error-coder';
+import type { Converter } from './types';
+import { AdaptyNativeErrorCoder } from './adapty-native-error';
+import { AdaptyPaywallProductCoder } from './adapty-paywall-product';
+import { AdaptyProfileCoder } from './adapty-profile';
+import { AdaptyPurchaseResultCoder } from './adapty-purchase-result';
+import type {
+ AdaptyPaywallProduct,
+ AdaptyProfile,
+ AdaptyPurchaseResult,
+} from '@/types';
+import {
+ PaywallEventId,
+ type PaywallEventView,
+ type ParsedPaywallEvent,
+} from '@/types/paywall-events';
+
+// Re-export types for convenience
+export {
+ PaywallEventId,
+ type PaywallEventIdType,
+ type PaywallEventView,
+ type PaywallDidAppearEvent,
+ type PaywallDidDisappearEvent,
+ type PaywallDidPerformActionEvent,
+ type PaywallDidSelectProductEvent,
+ type PaywallDidStartPurchaseEvent,
+ type PaywallDidFinishPurchaseEvent,
+ type PaywallDidFailPurchaseEvent,
+ type PaywallDidStartRestoreEvent,
+ type PaywallDidFinishRestoreEvent,
+ type PaywallDidFailRestoreEvent,
+ type PaywallDidFailRenderingEvent,
+ type PaywallDidFailLoadingProductsEvent,
+ type PaywallDidFinishWebPaymentNavigationEvent,
+ type ParsedPaywallEvent,
+} from '@/types/paywall-events';
+
+// Parser
+export function parsePaywallEvent(
+ input: string,
+ ctx?: LogContext,
+): ParsedPaywallEvent | null {
+ let obj: Record;
+ try {
+ obj = JSON.parse(input);
+ } catch (error) {
+ throw AdaptyError.failedToDecode(
+ `Failed to decode event: ${(error as Error)?.message}`,
+ );
+ }
+
+ const eventId = obj['id'] as string | undefined;
+ if (!eventId?.startsWith('paywall_view_')) {
+ return null;
+ }
+
+ const viewObj = obj['view'] as Record;
+ const view: PaywallEventView = {
+ id: viewObj['id'] as string,
+ placementId: viewObj['placement_id'] as string | undefined,
+ variationId: viewObj['variation_id'] as string | undefined,
+ };
+
+ switch (eventId) {
+ case PaywallEventId.DidAppear:
+ return {
+ id: eventId,
+ view,
+ };
+
+ case PaywallEventId.DidDisappear:
+ return {
+ id: eventId,
+ view,
+ };
+
+ case PaywallEventId.DidPerformAction: {
+ const actionObj = obj['action'] as Record;
+ return {
+ id: eventId,
+ view,
+ action: {
+ type: actionObj['type'] as
+ | 'close'
+ | 'system_back'
+ | 'open_url'
+ | 'custom',
+ value: actionObj['value'] as string | undefined,
+ },
+ };
+ }
+
+ case PaywallEventId.DidSelectProduct:
+ return {
+ id: eventId,
+ view,
+ productId: (obj['product_id'] as string) ?? '',
+ };
+
+ case PaywallEventId.DidStartPurchase:
+ return {
+ id: eventId,
+ view,
+ product: getPaywallCoder('product', ctx)!.decode(
+ obj['product'],
+ ) as AdaptyPaywallProduct,
+ };
+
+ case PaywallEventId.DidFinishPurchase:
+ return {
+ id: eventId,
+ view,
+ purchaseResult: getPaywallCoder('purchaseResult', ctx)!.decode(
+ obj['purchased_result'],
+ ) as AdaptyPurchaseResult,
+ product: getPaywallCoder('product', ctx)!.decode(
+ obj['product'],
+ ) as AdaptyPaywallProduct,
+ };
+
+ case PaywallEventId.DidFailPurchase: {
+ const errorCoder = getPaywallCoder('error', ctx) as ErrorConverter;
+ const decodedError = errorCoder.decode(obj['error']);
+ return {
+ id: eventId,
+ view,
+ error: errorCoder.getError(decodedError),
+ product: getPaywallCoder('product', ctx)!.decode(
+ obj['product'],
+ ) as AdaptyPaywallProduct,
+ };
+ }
+
+ case PaywallEventId.DidStartRestore:
+ return {
+ id: eventId,
+ view,
+ };
+
+ case PaywallEventId.DidFinishRestore:
+ return {
+ id: eventId,
+ view,
+ profile: getPaywallCoder('profile', ctx)!.decode(
+ obj['profile'],
+ ) as AdaptyProfile,
+ };
+
+ case PaywallEventId.DidFailRestore: {
+ const errorCoder = getPaywallCoder('error', ctx) as ErrorConverter;
+ const decodedError = errorCoder.decode(obj['error']);
+ return {
+ id: eventId,
+ view,
+ error: errorCoder.getError(decodedError),
+ };
+ }
+
+ case PaywallEventId.DidFailRendering: {
+ const errorCoder = getPaywallCoder('error', ctx) as ErrorConverter;
+ const decodedError = errorCoder.decode(obj['error']);
+ return {
+ id: eventId,
+ view,
+ error: errorCoder.getError(decodedError),
+ };
+ }
+
+ case PaywallEventId.DidFailLoadingProducts: {
+ const errorCoder = getPaywallCoder('error', ctx) as ErrorConverter;
+ const decodedError = errorCoder.decode(obj['error']);
+ return {
+ id: eventId,
+ view,
+ error: errorCoder.getError(decodedError),
+ };
+ }
+
+ case PaywallEventId.DidFinishWebPaymentNavigation:
+ return {
+ id: eventId,
+ view,
+ product: obj['product']
+ ? (getPaywallCoder('product', ctx)!.decode(
+ obj['product'],
+ ) as AdaptyPaywallProduct)
+ : undefined,
+ error: obj['error']
+ ? (() => {
+ const errorCoder = getPaywallCoder(
+ 'error',
+ ctx,
+ ) as ErrorConverter;
+ const decodedError = errorCoder.decode(obj['error']);
+ return errorCoder.getError(decodedError);
+ })()
+ : undefined,
+ };
+
+ default:
+ return null;
+ }
+}
+
+type PaywallCoderType = 'product' | 'profile' | 'purchaseResult' | 'error';
+
+function getPaywallCoder(
+ type: PaywallCoderType,
+ _ctx?: LogContext,
+): Converter | ErrorConverter {
+ switch (type) {
+ case 'product':
+ return new AdaptyPaywallProductCoder();
+ case 'profile':
+ return new AdaptyProfileCoder();
+ case 'purchaseResult':
+ return new AdaptyPurchaseResultCoder();
+ case 'error':
+ return new AdaptyNativeErrorCoder();
+ }
+}
diff --git a/src/coders/parse.ts b/src/coders/parse.ts
index ca9b10de..bdfe555c 100644
--- a/src/coders/parse.ts
+++ b/src/coders/parse.ts
@@ -131,58 +131,6 @@ export function parseCommonEvent(
}
}
-export function parsePaywallEvent(
- input: string,
- ctx?: LogContext,
-): Record {
- const log = ctx?.decode({ methodName: 'parsePaywallEvent' });
- log?.start({ input });
-
- let obj: Record;
- try {
- obj = JSON.parse(input);
- } catch (error) {
- throw AdaptyError.failedToDecode(
- `Failed to decode event: ${(error as Error)?.message}`,
- );
- }
-
- const result: Record = {};
-
- if (obj.hasOwnProperty('id')) {
- result['id'] = obj['id'];
- }
- if (obj.hasOwnProperty('profile')) {
- result['profile'] = getCoder('AdaptyProfile', ctx)?.decode(obj['profile']);
- }
- if (obj.hasOwnProperty('product')) {
- result['product'] = getCoder('AdaptyPaywallProduct', ctx)?.decode(
- obj['product'],
- );
- }
- if (obj.hasOwnProperty('error')) {
- const errorCoder = getCoder('AdaptyError', ctx) as ErrorConverter;
- const decodedError = errorCoder?.decode(obj['error']);
- result['error'] = errorCoder?.getError(decodedError as any);
- }
- if (obj.hasOwnProperty('action')) {
- result['action'] = obj['action'];
- }
- if (obj.hasOwnProperty('view')) {
- result['view'] = obj['view'];
- }
- if (obj.hasOwnProperty('product_id')) {
- result['product_id'] = obj['product_id'];
- }
- if (obj.hasOwnProperty('purchased_result')) {
- result['purchased_result'] = getCoder('AdaptyPurchaseResult', ctx)?.decode(
- obj['purchased_result'],
- );
- }
-
- return result;
-}
-
function getCoder(
type: AdaptyType,
ctx?: LogContext,
diff --git a/src/mock/mock-request-handler.ts b/src/mock/mock-request-handler.ts
index 54efa4ed..a1648012 100644
--- a/src/mock/mock-request-handler.ts
+++ b/src/mock/mock-request-handler.ts
@@ -1,7 +1,8 @@
import { EmitterSubscription } from 'react-native';
import { LogContext } from '@/logger';
import type { AdaptyType } from '@/coders/parse';
-import { parseCommonEvent, parsePaywallEvent } from '@/coders/parse';
+import { parseCommonEvent } from '@/coders/parse';
+import { parsePaywallEvent } from '@/coders/parse-paywall';
import { parseOnboardingEvent } from '@/coders/parse-onboarding';
import { MockStore } from './mock-store';
import type { AdaptyMockConfig } from './types';
diff --git a/src/native-request-handler/native-request-handler.ts b/src/native-request-handler/native-request-handler.ts
index 499f921d..8993f754 100644
--- a/src/native-request-handler/native-request-handler.ts
+++ b/src/native-request-handler/native-request-handler.ts
@@ -7,11 +7,8 @@ import {
import { AdaptyError } from '@/adapty-error';
import { LogContext } from '@/logger';
import { parseMethodResult } from '@/coders';
-import {
- AdaptyType,
- parseCommonEvent,
- parsePaywallEvent,
-} from '@/coders/parse';
+import { AdaptyType, parseCommonEvent } from '@/coders/parse';
+import { parsePaywallEvent } from '@/coders/parse-paywall';
import { parseOnboardingEvent } from '@/coders/parse-onboarding';
const KEY_HANDLER_NAME = 'HANDLER';
diff --git a/src/types/api.d.ts b/src/types/api.d.ts
index 8662407b..8607d1d2 100644
--- a/src/types/api.d.ts
+++ b/src/types/api.d.ts
@@ -595,6 +595,7 @@ export interface components {
google_enable_pending_prepaid_plans?: boolean;
google_local_access_level_allowed?: boolean;
ip_address_collection_disabled?: boolean;
+ clear_data_on_backup?: boolean;
server_cluster?: 'default' | 'eu' | 'cn';
backend_proxy_host?: string;
backend_proxy_port?: number;
diff --git a/src/types/inputs.ts b/src/types/inputs.ts
index 72e4f13f..eb1aad22 100644
--- a/src/types/inputs.ts
+++ b/src/types/inputs.ts
@@ -166,6 +166,11 @@ export interface ActivateParamsInput {
*/
idfaCollectionDisabled?: boolean;
appAccountToken?: string;
+ /**
+ * Controls whether the SDK will create a new profile when the app is restored from an iCloud backup
+ * @defaultValue `false`
+ */
+ clearDataOnBackup?: boolean;
};
android?: {
/**
diff --git a/src/types/paywall-events.ts b/src/types/paywall-events.ts
new file mode 100644
index 00000000..5790c2d5
--- /dev/null
+++ b/src/types/paywall-events.ts
@@ -0,0 +1,125 @@
+import { AdaptyError } from '@/adapty-error';
+import type {
+ AdaptyPaywallProduct,
+ AdaptyProfile,
+ AdaptyPurchaseResult,
+} from '@/types';
+
+// Paywall Event IDs
+export const PaywallEventId = {
+ DidAppear: 'paywall_view_did_appear',
+ DidDisappear: 'paywall_view_did_disappear',
+ DidPerformAction: 'paywall_view_did_perform_action',
+ DidSelectProduct: 'paywall_view_did_select_product',
+ DidStartPurchase: 'paywall_view_did_start_purchase',
+ DidFinishPurchase: 'paywall_view_did_finish_purchase',
+ DidFailPurchase: 'paywall_view_did_fail_purchase',
+ DidStartRestore: 'paywall_view_did_start_restore',
+ DidFinishRestore: 'paywall_view_did_finish_restore',
+ DidFailRestore: 'paywall_view_did_fail_restore',
+ DidFailRendering: 'paywall_view_did_fail_rendering',
+ DidFailLoadingProducts: 'paywall_view_did_fail_loading_products',
+ DidFinishWebPaymentNavigation:
+ 'paywall_view_did_finish_web_payment_navigation',
+} as const;
+
+export type PaywallEventIdType =
+ (typeof PaywallEventId)[keyof typeof PaywallEventId];
+
+// Event View
+export interface PaywallEventView {
+ id: string;
+ placementId?: string;
+ variationId?: string;
+}
+
+// Base Event
+interface BasePaywallEvent {
+ id: PaywallEventIdType;
+ view: PaywallEventView;
+}
+
+// Event Types
+export interface PaywallDidAppearEvent extends BasePaywallEvent {
+ id: typeof PaywallEventId.DidAppear;
+}
+
+export interface PaywallDidDisappearEvent extends BasePaywallEvent {
+ id: typeof PaywallEventId.DidDisappear;
+}
+
+export interface PaywallDidPerformActionEvent extends BasePaywallEvent {
+ id: typeof PaywallEventId.DidPerformAction;
+ action: {
+ type: 'close' | 'system_back' | 'open_url' | 'custom';
+ value?: string; // for open_url and custom
+ };
+}
+
+export interface PaywallDidSelectProductEvent extends BasePaywallEvent {
+ id: typeof PaywallEventId.DidSelectProduct;
+ productId: string;
+}
+
+export interface PaywallDidStartPurchaseEvent extends BasePaywallEvent {
+ id: typeof PaywallEventId.DidStartPurchase;
+ product: AdaptyPaywallProduct;
+}
+
+export interface PaywallDidFinishPurchaseEvent extends BasePaywallEvent {
+ id: typeof PaywallEventId.DidFinishPurchase;
+ purchaseResult: AdaptyPurchaseResult;
+ product: AdaptyPaywallProduct;
+}
+
+export interface PaywallDidFailPurchaseEvent extends BasePaywallEvent {
+ id: typeof PaywallEventId.DidFailPurchase;
+ error: AdaptyError;
+ product: AdaptyPaywallProduct;
+}
+
+export interface PaywallDidStartRestoreEvent extends BasePaywallEvent {
+ id: typeof PaywallEventId.DidStartRestore;
+}
+
+export interface PaywallDidFinishRestoreEvent extends BasePaywallEvent {
+ id: typeof PaywallEventId.DidFinishRestore;
+ profile: AdaptyProfile;
+}
+
+export interface PaywallDidFailRestoreEvent extends BasePaywallEvent {
+ id: typeof PaywallEventId.DidFailRestore;
+ error: AdaptyError;
+}
+
+export interface PaywallDidFailRenderingEvent extends BasePaywallEvent {
+ id: typeof PaywallEventId.DidFailRendering;
+ error: AdaptyError;
+}
+
+export interface PaywallDidFailLoadingProductsEvent extends BasePaywallEvent {
+ id: typeof PaywallEventId.DidFailLoadingProducts;
+ error: AdaptyError;
+}
+
+export interface PaywallDidFinishWebPaymentNavigationEvent
+ extends BasePaywallEvent {
+ id: typeof PaywallEventId.DidFinishWebPaymentNavigation;
+ product?: AdaptyPaywallProduct;
+ error?: AdaptyError;
+}
+
+export type ParsedPaywallEvent =
+ | PaywallDidAppearEvent
+ | PaywallDidDisappearEvent
+ | PaywallDidPerformActionEvent
+ | PaywallDidSelectProductEvent
+ | PaywallDidStartPurchaseEvent
+ | PaywallDidFinishPurchaseEvent
+ | PaywallDidFailPurchaseEvent
+ | PaywallDidStartRestoreEvent
+ | PaywallDidFinishRestoreEvent
+ | PaywallDidFailRestoreEvent
+ | PaywallDidFailRenderingEvent
+ | PaywallDidFailLoadingProductsEvent
+ | PaywallDidFinishWebPaymentNavigationEvent;
diff --git a/src/ui/AdaptyPaywallView.tsx b/src/ui/AdaptyPaywallView.tsx
index 9bb6f057..258cd524 100644
--- a/src/ui/AdaptyPaywallView.tsx
+++ b/src/ui/AdaptyPaywallView.tsx
@@ -18,13 +18,11 @@ export type Props = ViewProps & {
paywall: AdaptyPaywall;
params?: CreatePaywallViewParamsInput;
onCloseButtonPress?: EventHandlers['onCloseButtonPress'];
- onAndroidSystemBack?: EventHandlers['onAndroidSystemBack'];
onProductSelected?: EventHandlers['onProductSelected'];
onPurchaseStarted?: EventHandlers['onPurchaseStarted'];
onPurchaseCompleted?: EventHandlers['onPurchaseCompleted'];
onPurchaseFailed?: EventHandlers['onPurchaseFailed'];
onRestoreStarted?: EventHandlers['onRestoreStarted'];
- onPaywallClosed?: EventHandlers['onPaywallClosed'];
onPaywallShown?: EventHandlers['onPaywallShown'];
onWebPaymentNavigationFinished?: EventHandlers['onWebPaymentNavigationFinished'];
onRestoreCompleted?: EventHandlers['onRestoreCompleted'];
@@ -43,13 +41,11 @@ const AdaptyPaywallViewComponent: React.FC = ({
paywall,
params,
onCloseButtonPress,
- onAndroidSystemBack,
onProductSelected,
onPurchaseStarted,
onPurchaseCompleted,
onPurchaseFailed,
onRestoreStarted,
- onPaywallClosed,
onPaywallShown,
onWebPaymentNavigationFinished,
onRestoreCompleted,
@@ -78,13 +74,11 @@ const AdaptyPaywallViewComponent: React.FC = ({
const handlers: Partial = {};
if (onCloseButtonPress) handlers.onCloseButtonPress = onCloseButtonPress;
- if (onAndroidSystemBack) handlers.onAndroidSystemBack = onAndroidSystemBack;
if (onProductSelected) handlers.onProductSelected = onProductSelected;
if (onPurchaseStarted) handlers.onPurchaseStarted = onPurchaseStarted;
if (onPurchaseCompleted) handlers.onPurchaseCompleted = onPurchaseCompleted;
if (onPurchaseFailed) handlers.onPurchaseFailed = onPurchaseFailed;
if (onRestoreStarted) handlers.onRestoreStarted = onRestoreStarted;
- if (onPaywallClosed) handlers.onPaywallClosed = onPaywallClosed;
if (onPaywallShown) handlers.onPaywallShown = onPaywallShown;
if (onWebPaymentNavigationFinished)
handlers.onWebPaymentNavigationFinished = onWebPaymentNavigationFinished;
@@ -99,13 +93,11 @@ const AdaptyPaywallViewComponent: React.FC = ({
return handlers;
}, [
onCloseButtonPress,
- onAndroidSystemBack,
onProductSelected,
onPurchaseStarted,
onPurchaseCompleted,
onPurchaseFailed,
onRestoreStarted,
- onPaywallClosed,
onPaywallShown,
onWebPaymentNavigationFinished,
onRestoreCompleted,
diff --git a/src/ui/create-paywall-event-handlers.test.ts b/src/ui/create-paywall-event-handlers.test.ts
index 77844a15..c2b6846f 100644
--- a/src/ui/create-paywall-event-handlers.test.ts
+++ b/src/ui/create-paywall-event-handlers.test.ts
@@ -71,9 +71,9 @@ describe('createPaywallEventHandlers', () => {
createPaywallEventHandlers({}, 'test-id');
- // Should register 5 default handlers:
- // onCloseButtonPress, onAndroidSystemBack, onRestoreCompleted, onPurchaseCompleted, onUrlPress
- expect(addListener).toHaveBeenCalledTimes(5);
+ // Should register 6 default handlers:
+ // onCloseButtonPress, onAndroidSystemBack, onRestoreCompleted, onRenderingFailed, onPurchaseCompleted, onUrlPress
+ expect(addListener).toHaveBeenCalledTimes(6);
const calls = (addListener as jest.Mock).mock.calls;
const registeredEvents = calls.map(call => call[0]);
@@ -81,6 +81,7 @@ describe('createPaywallEventHandlers', () => {
expect(registeredEvents).toContain('onCloseButtonPress');
expect(registeredEvents).toContain('onAndroidSystemBack');
expect(registeredEvents).toContain('onRestoreCompleted');
+ expect(registeredEvents).toContain('onRenderingFailed');
expect(registeredEvents).toContain('onPurchaseCompleted');
expect(registeredEvents).toContain('onUrlPress');
});
@@ -104,8 +105,8 @@ describe('createPaywallEventHandlers', () => {
'test-id',
);
- // Should register 5 defaults + 2 custom = 7 handlers
- expect(addListener).toHaveBeenCalledTimes(7);
+ // Should register 6 defaults + 2 custom = 8 handlers
+ expect(addListener).toHaveBeenCalledTimes(8);
const calls = (addListener as jest.Mock).mock.calls;
const productSelectedCall = calls.find(
@@ -181,8 +182,8 @@ describe('createPaywallEventHandlers', () => {
expect(closeCall[1]).toBe(customCloseHandler);
expect(restoreCall[1]).toBe(customRestoreHandler);
- // Should still have only 5 handlers (not 7), because custom ones override defaults
- expect(addListener).toHaveBeenCalledTimes(5);
+ // Should still have only 6 handlers (not 8), because custom ones override defaults
+ expect(addListener).toHaveBeenCalledTimes(6);
});
it('creates default onRequestClose when not provided', () => {
diff --git a/src/ui/onboarding-view-controller.ts b/src/ui/onboarding-view-controller.ts
index b0415cad..65c67711 100644
--- a/src/ui/onboarding-view-controller.ts
+++ b/src/ui/onboarding-view-controller.ts
@@ -54,7 +54,7 @@ export class OnboardingViewController {
const data: Req['AdaptyUICreateOnboardingView.Request'] = {
method: methodKey,
onboarding: coder.encode(onboarding),
- ...(encodedParams as any),
+ ...encodedParams,
};
const body = JSON.stringify(data);
diff --git a/src/ui/types.ts b/src/ui/types.ts
index ae8b60e3..8b097807 100644
--- a/src/ui/types.ts
+++ b/src/ui/types.ts
@@ -326,6 +326,7 @@ export const DEFAULT_EVENT_HANDLERS: Partial = {
onCloseButtonPress: () => true,
onAndroidSystemBack: () => true,
onRestoreCompleted: () => true,
+ onRenderingFailed: () => true,
onPurchaseCompleted: (purchaseResult: AdaptyPurchaseResult) =>
purchaseResult.type !== 'user_cancelled',
onUrlPress: url => {
diff --git a/src/ui/view-controller.test.ts b/src/ui/view-controller.test.ts
index b607ae82..b7649717 100644
--- a/src/ui/view-controller.test.ts
+++ b/src/ui/view-controller.test.ts
@@ -21,6 +21,7 @@ jest.mock('./view-emitter', () => {
return {
ViewEmitter: jest.fn().mockImplementation(() => ({
addListener: jest.fn(),
+ addInternalListener: jest.fn(),
removeAllListeners: jest.fn(),
})),
};
@@ -127,7 +128,7 @@ describe('ViewController', () => {
});
describe('dismiss', () => {
- it('calls bridge and unsubscribes listeners', async () => {
+ it('calls bridge and registers internal cleanup handler', async () => {
const { AdaptyPaywallCoder } = jest.requireMock(
'@/coders/adapty-paywall',
);
@@ -140,15 +141,23 @@ describe('ViewController', () => {
.mockResolvedValueOnce(undefined); // dismiss
const { ViewEmitter } = jest.requireMock('./view-emitter');
+ const addInternalListenerMock = jest.fn();
const removeAllListenersMock = jest.fn();
(ViewEmitter as unknown as jest.Mock).mockImplementation(() => ({
addListener: jest.fn(),
+ addInternalListener: addInternalListenerMock,
removeAllListeners: removeAllListenersMock,
}));
const view = await ViewController.create(paywall, {} as any);
view.setEventHandlers({ onCloseButtonPress: () => true });
+ // Verify internal handler was registered for cleanup
+ expect(addInternalListenerMock).toHaveBeenCalledWith(
+ 'onPaywallClosed',
+ expect.any(Function),
+ );
+
await view.dismiss();
expect($bridge.request).toHaveBeenLastCalledWith(
@@ -157,7 +166,10 @@ describe('ViewController', () => {
'Void',
expect.any(Object),
);
- expect(removeAllListenersMock).toHaveBeenCalled();
+
+ // Cleanup now happens via internal handler, not directly in dismiss()
+ // So removeAllListeners should NOT be called immediately after dismiss
+ expect(removeAllListenersMock).not.toHaveBeenCalled();
});
it('throws if id is null', async () => {
@@ -181,6 +193,7 @@ describe('ViewController', () => {
const addListener = jest.fn();
(ViewEmitter as unknown as jest.Mock).mockImplementation(() => ({
addListener,
+ addInternalListener: jest.fn(),
removeAllListeners: jest.fn(),
}));
@@ -229,6 +242,7 @@ describe('ViewController', () => {
// Mock ViewEmitter instance BEFORE create
(ViewEmitter as unknown as jest.Mock).mockImplementation(() => ({
addListener,
+ addInternalListener: jest.fn(),
removeAllListeners,
}));
@@ -292,6 +306,7 @@ describe('ViewController', () => {
});
(ViewEmitter as unknown as jest.Mock).mockImplementation(() => ({
addListener,
+ addInternalListener: jest.fn(),
removeAllListeners: jest.fn(),
}));
@@ -347,6 +362,7 @@ describe('ViewController', () => {
});
(ViewEmitter as unknown as jest.Mock).mockImplementation(() => ({
addListener,
+ addInternalListener: jest.fn(),
removeAllListeners: jest.fn(),
}));
@@ -357,15 +373,17 @@ describe('ViewController', () => {
const view = await ViewController.create(paywall, {} as any);
// After create, default handlers should be registered
- // (onCloseButtonPress, onAndroidSystemBack, onRestoreCompleted, onPurchaseCompleted, onUrlPress)
+ // (onCloseButtonPress, onAndroidSystemBack, onRestoreCompleted, onRenderingFailed, onPurchaseCompleted, onUrlPress)
expect(handlers.has('onCloseButtonPress')).toBe(true);
expect(handlers.has('onAndroidSystemBack')).toBe(true);
expect(handlers.has('onRestoreCompleted')).toBe(true);
+ expect(handlers.has('onRenderingFailed')).toBe(true);
expect(handlers.has('onPurchaseCompleted')).toBe(true);
expect(handlers.has('onUrlPress')).toBe(true);
const defaultOnAndroidSystemBack = handlers.get('onAndroidSystemBack');
const defaultOnRestoreCompleted = handlers.get('onRestoreCompleted');
+ const defaultOnRenderingFailed = handlers.get('onRenderingFailed');
const defaultOnUrlPress = handlers.get('onUrlPress');
// Now override only onCloseButtonPress
@@ -382,6 +400,7 @@ describe('ViewController', () => {
expect(handlers.get('onRestoreCompleted')).toBe(
defaultOnRestoreCompleted,
);
+ expect(handlers.get('onRenderingFailed')).toBe(defaultOnRenderingFailed);
expect(handlers.get('onUrlPress')).toBe(defaultOnUrlPress);
expect(handlers.has('onPurchaseCompleted')).toBe(true);
});
@@ -398,6 +417,7 @@ describe('ViewController', () => {
const addListener = jest.fn();
(ViewEmitter as unknown as jest.Mock).mockImplementation(() => ({
addListener,
+ addInternalListener: jest.fn(),
removeAllListeners: jest.fn(),
}));
diff --git a/src/ui/view-controller.ts b/src/ui/view-controller.ts
index 62cc44f5..90097ea5 100644
--- a/src/ui/view-controller.ts
+++ b/src/ui/view-controller.ts
@@ -72,9 +72,18 @@ export class ViewController {
);
view.id = result.id;
+ view.viewEmitter = new ViewEmitter(result.id);
view.setEventHandlers(DEFAULT_EVENT_HANDLERS);
+ // Register internal handler for cleanup
+ view.viewEmitter.addInternalListener('onPaywallClosed', () => {
+ // Called AFTER client's onPaywallClosed handler
+ if (view.viewEmitter) {
+ view.viewEmitter.removeAllListeners();
+ }
+ });
+
return view;
}
@@ -184,10 +193,6 @@ export class ViewController {
} satisfies Req['AdaptyUIDismissPaywallView.Request']);
await this.handle(methodKey, body, 'Void', ctx, log);
-
- if (this.viewEmitter) {
- this.viewEmitter.removeAllListeners();
- }
}
/**
@@ -259,6 +264,7 @@ export class ViewController {
* - `onCloseButtonPress` - closes paywall (returns `true`)
* - `onAndroidSystemBack` - closes paywall (returns `true`)
* - `onRestoreCompleted` - closes paywall (returns `true`)
+ * - `onRenderingFailed` - closes paywall (returns `true`)
* - `onPurchaseCompleted` - closes paywall on success (returns `purchaseResult.type !== 'user_cancelled'`)
* - `onUrlPress` - opens URL and keeps paywall open (returns `false`)
*
diff --git a/src/ui/view-emitter.ts b/src/ui/view-emitter.ts
index a2811af4..ac874b7b 100644
--- a/src/ui/view-emitter.ts
+++ b/src/ui/view-emitter.ts
@@ -1,6 +1,12 @@
import type { EventHandlers } from './types';
import { $bridge } from '@/bridge';
import { EmitterSubscription } from 'react-native';
+import {
+ ParsedPaywallEvent,
+ PaywallEventId,
+ PaywallEventIdType,
+} from '@/types/paywall-events';
+import { LogContext } from '@/logger';
type EventName = keyof EventHandlers;
@@ -30,10 +36,15 @@ export class ViewEmitter {
EventName,
{
handler: EventHandlers[EventName];
- config: (typeof HANDLER_TO_EVENT_CONFIG)[EventName];
onRequestClose: () => Promise;
}
> = new Map();
+ private internalHandlers: Map<
+ EventName,
+ {
+ handler: (event: ParsedPaywallEvent) => void;
+ }
+ > = new Map();
constructor(viewId: string) {
this.viewId = viewId;
@@ -44,206 +55,263 @@ export class ViewEmitter {
callback: EventHandlers[EventName],
onRequestClose: () => Promise,
): EmitterSubscription {
- const viewId = this.viewId;
- const config = HANDLER_TO_EVENT_CONFIG[event];
+ const nativeEvent = HANDLER_TO_NATIVE_EVENT[event];
- if (!config) {
- throw new Error(`No event config found for handler: ${event}`);
+ if (!nativeEvent) {
+ throw new Error(`No native event mapping found for handler: ${event}`);
}
// Replace existing handler for this event type
this.handlers.set(event, {
handler: callback,
- config,
onRequestClose,
});
- if (!this.eventListeners.has(config.nativeEvent)) {
- const handlers = this.handlers; // Capture the reference
+ // If no subscription to native event exists - create one
+ if (!this.eventListeners.has(nativeEvent)) {
const subscription = $bridge.addEventListener(
- config.nativeEvent,
- function (arg) {
- const eventViewId = this.rawValue['view']?.['id'] ?? null;
- if (viewId !== eventViewId) {
- return;
- }
+ nativeEvent,
+ this.createEventHandler(nativeEvent),
+ );
+ this.eventListeners.set(nativeEvent, subscription);
+ }
- // Get all possible handler names for this native event
- const possibleHandlers =
- NATIVE_EVENT_TO_HANDLERS[config.nativeEvent] || [];
-
- for (const handlerName of possibleHandlers) {
- const handlerData = handlers.get(handlerName);
- if (!handlerData) {
- continue; // Handler not registered for this view
- }
-
- const {
- handler,
- config: handlerConfig,
- onRequestClose,
- } = handlerData;
-
- if (
- handlerConfig.propertyMap &&
- (arg as any)['action']?.type !==
- handlerConfig.propertyMap['action']
- ) {
- continue;
- }
-
- const callbackArgs = extractCallbackArgs(
- handlerName,
- arg as Record,
- );
- const cb = handler as (...args: typeof callbackArgs) => boolean;
- const shouldClose = cb.apply(null, callbackArgs);
-
- if (shouldClose) {
- onRequestClose().catch(() => {
- // Ignore errors from onRequestClose to avoid breaking event handling
- });
- }
- }
- },
+ return this.eventListeners.get(nativeEvent)!;
+ }
+
+ /**
+ * Adds an internal event handler.
+ * Internal handlers:
+ * - Are called AFTER client handlers
+ * - Do NOT return boolean (don't affect auto-dismiss)
+ * - Are used for internal SDK logic (e.g., cleanup)
+ * @internal
+ */
+ public addInternalListener(
+ event: EventName,
+ callback: (event: ParsedPaywallEvent) => void,
+ ): void {
+ const nativeEvent = HANDLER_TO_NATIVE_EVENT[event];
+
+ if (!nativeEvent) {
+ throw new Error(`No native event mapping found for handler: ${event}`);
+ }
+
+ // Replace existing internal handler for this event
+ this.internalHandlers.set(event, {
+ handler: callback,
+ });
+
+ // If no subscription to native event exists - create one
+ if (!this.eventListeners.has(nativeEvent)) {
+ const subscription = $bridge.addEventListener(
+ nativeEvent,
+ this.createEventHandler(nativeEvent),
);
- this.eventListeners.set(config.nativeEvent, subscription);
+ this.eventListeners.set(nativeEvent, subscription);
}
+ }
+
+ private createEventHandler(nativeEvent: PaywallEventIdType) {
+ return (parsedEvent: ParsedPaywallEvent | null) => {
+ if (!parsedEvent) {
+ return;
+ }
+
+ const eventViewId = parsedEvent.view.id;
+ if (eventViewId !== this.viewId) {
+ return; // Event for different view
+ }
+
+ const ctx = new LogContext();
+ const log = ctx.event({ methodName: nativeEvent });
+ log.start({ viewId: eventViewId, eventId: parsedEvent.id });
+
+ // Resolve handler name from event
+ const resolver = NATIVE_EVENT_RESOLVER[nativeEvent];
+ if (!resolver) {
+ log.failed({ reason: 'no_resolver', nativeEvent });
+ return;
+ }
+
+ const resolvedHandler = resolver(parsedEvent);
+ if (!resolvedHandler) {
+ // Event doesn't match any handler (e.g., unknown action type)
+ return;
+ }
+
+ // TypeScript doesn't narrow the type after the null check, so we assert it
+ const handlerName = resolvedHandler as EventName;
+
+ let hasError = false;
+
+ // 1. Client handlers
+ const handlerData = this.handlers.get(handlerName);
+ if (handlerData) {
+ const { handler, onRequestClose } = handlerData;
+ const callbackArgs = extractCallbackArgs(handlerName, parsedEvent);
+ const callback = handler as (
+ ...args: ExtractedArgs
+ ) => boolean;
+
+ try {
+ const shouldClose = callback(...callbackArgs);
+
+ if (shouldClose) {
+ onRequestClose().catch(error => {
+ log.failed({
+ error,
+ handlerName,
+ viewId: eventViewId,
+ eventId: parsedEvent.id,
+ reason: 'on_request_close_failed',
+ });
+ });
+ }
+ } catch (error) {
+ hasError = true;
+ log.failed({
+ error,
+ handlerName,
+ viewId: eventViewId,
+ eventId: parsedEvent.id,
+ reason: 'handler_error',
+ });
+ }
+ }
+
+ // 2. Internal handlers
+ const internalHandlerData = this.internalHandlers.get(handlerName);
+ if (internalHandlerData) {
+ try {
+ internalHandlerData.handler(parsedEvent);
+ } catch (error) {
+ hasError = true;
+ log.failed({
+ error,
+ handlerName: `${handlerName} (internal)`,
+ viewId: eventViewId,
+ eventId: parsedEvent.id,
+ reason: 'internal_handler_failed',
+ });
+ }
+ }
- return this.eventListeners.get(config.nativeEvent)!;
+ if (!hasError) {
+ log.success({ viewId: eventViewId, eventId: parsedEvent.id });
+ }
+ };
}
public removeAllListeners() {
this.eventListeners.forEach(subscription => subscription.remove());
this.eventListeners.clear();
this.handlers.clear();
+ this.internalHandlers.clear();
}
}
-type UiEventMapping = {
- [nativeEventId: string]: {
- handlerName: keyof EventHandlers;
- propertyMap?: {
- [key: string]: string;
+/**
+ * Resolves native event to handler name based on event data
+ */
+const NATIVE_EVENT_RESOLVER: Record<
+ PaywallEventIdType,
+ (event: ParsedPaywallEvent) => EventName | null
+> = {
+ paywall_view_did_perform_action: event => {
+ if (event.id !== PaywallEventId.DidPerformAction) return null;
+
+ const actionMap: Record = {
+ close: 'onCloseButtonPress',
+ system_back: 'onAndroidSystemBack',
+ open_url: 'onUrlPress',
+ custom: 'onCustomAction',
};
- }[];
+
+ return actionMap[event.action.type] || null;
+ },
+ paywall_view_did_appear: () => 'onPaywallShown',
+ paywall_view_did_disappear: () => 'onPaywallClosed',
+ paywall_view_did_select_product: () => 'onProductSelected',
+ paywall_view_did_start_purchase: () => 'onPurchaseStarted',
+ paywall_view_did_finish_purchase: () => 'onPurchaseCompleted',
+ paywall_view_did_fail_purchase: () => 'onPurchaseFailed',
+ paywall_view_did_start_restore: () => 'onRestoreStarted',
+ paywall_view_did_finish_restore: () => 'onRestoreCompleted',
+ paywall_view_did_fail_restore: () => 'onRestoreFailed',
+ paywall_view_did_fail_rendering: () => 'onRenderingFailed',
+ paywall_view_did_fail_loading_products: () => 'onLoadingProductsFailed',
+ paywall_view_did_finish_web_payment_navigation: () =>
+ 'onWebPaymentNavigationFinished',
};
-const UI_EVENT_MAPPINGS: UiEventMapping = {
- paywall_view_did_perform_action: [
- {
- handlerName: 'onCloseButtonPress',
- propertyMap: {
- action: 'close',
- },
- },
- {
- handlerName: 'onAndroidSystemBack',
- propertyMap: {
- action: 'system_back',
- },
- },
- {
- handlerName: 'onUrlPress',
- propertyMap: {
- action: 'open_url',
- },
- },
- {
- handlerName: 'onCustomAction',
- propertyMap: {
- action: 'custom',
- },
- },
- ],
- paywall_view_did_select_product: [{ handlerName: 'onProductSelected' }],
- paywall_view_did_start_purchase: [{ handlerName: 'onPurchaseStarted' }],
- paywall_view_did_finish_purchase: [{ handlerName: 'onPurchaseCompleted' }],
- paywall_view_did_fail_purchase: [{ handlerName: 'onPurchaseFailed' }],
- paywall_view_did_start_restore: [{ handlerName: 'onRestoreStarted' }],
- paywall_view_did_appear: [{ handlerName: 'onPaywallShown' }],
- paywall_view_did_disappear: [{ handlerName: 'onPaywallClosed' }],
- paywall_view_did_finish_web_payment_navigation: [
- { handlerName: 'onWebPaymentNavigationFinished' },
- ],
- paywall_view_did_finish_restore: [{ handlerName: 'onRestoreCompleted' }],
- paywall_view_did_fail_restore: [{ handlerName: 'onRestoreFailed' }],
- paywall_view_did_fail_rendering: [{ handlerName: 'onRenderingFailed' }],
- paywall_view_did_fail_loading_products: [
- { handlerName: 'onLoadingProductsFailed' },
- ],
+/**
+ * Maps handler name to native event name
+ * Used in addListener/addInternalListener to subscribe to correct native event
+ */
+const HANDLER_TO_NATIVE_EVENT: Record = {
+ onCloseButtonPress: 'paywall_view_did_perform_action',
+ onAndroidSystemBack: 'paywall_view_did_perform_action',
+ onUrlPress: 'paywall_view_did_perform_action',
+ onCustomAction: 'paywall_view_did_perform_action',
+ onPaywallShown: 'paywall_view_did_appear',
+ onPaywallClosed: 'paywall_view_did_disappear',
+ onProductSelected: 'paywall_view_did_select_product',
+ onPurchaseStarted: 'paywall_view_did_start_purchase',
+ onPurchaseCompleted: 'paywall_view_did_finish_purchase',
+ onPurchaseFailed: 'paywall_view_did_fail_purchase',
+ onRestoreStarted: 'paywall_view_did_start_restore',
+ onRestoreCompleted: 'paywall_view_did_finish_restore',
+ onRestoreFailed: 'paywall_view_did_fail_restore',
+ onRenderingFailed: 'paywall_view_did_fail_rendering',
+ onLoadingProductsFailed: 'paywall_view_did_fail_loading_products',
+ onWebPaymentNavigationFinished:
+ 'paywall_view_did_finish_web_payment_navigation',
};
-const HANDLER_TO_EVENT_CONFIG: Record<
- EventName,
- {
- nativeEvent: string;
- propertyMap?: { [key: string]: string };
- handlerName: EventName;
- }
-> = Object.entries(UI_EVENT_MAPPINGS).reduce(
- (acc, [nativeEvent, mappings]) => {
- mappings.forEach(({ handlerName, propertyMap }) => {
- acc[handlerName] = {
- nativeEvent,
- propertyMap,
- handlerName,
- };
- });
- return acc;
- },
- {} as Record<
- EventName,
- {
- nativeEvent: string;
- propertyMap?: { [key: string]: string };
- handlerName: EventName;
- }
- >,
-);
-
-// Reverse mapping: nativeEvent -> EventName[]
-const NATIVE_EVENT_TO_HANDLERS: Record = Object.entries(
- HANDLER_TO_EVENT_CONFIG,
-).reduce(
- (acc, [handlerName, config]) => {
- if (!acc[config.nativeEvent]) {
- acc[config.nativeEvent] = [];
- }
- const handlers = acc[config.nativeEvent];
- if (handlers) {
- handlers.push(handlerName as EventName);
- }
- return acc;
- },
- {} as Record,
-);
-
-function extractCallbackArgs(
- handlerName: EventName,
- eventArg: Record,
-) {
- switch (handlerName) {
- case 'onProductSelected':
- return [eventArg['product_id']];
- case 'onPurchaseStarted':
- return [eventArg['product']];
- case 'onPurchaseCompleted':
- return [eventArg['purchased_result'], eventArg['product']];
- case 'onPurchaseFailed':
- return [eventArg['error'], eventArg['product']];
- case 'onRestoreCompleted':
- return [eventArg['profile']];
- case 'onRestoreFailed':
- case 'onRenderingFailed':
- case 'onLoadingProductsFailed':
- return [eventArg['error']];
- case 'onCustomAction':
- case 'onUrlPress':
- return [eventArg['action'].value];
- case 'onWebPaymentNavigationFinished':
- return [eventArg['product'], eventArg['error']];
- default:
- return [];
+type ExtractedArgs = Parameters<
+ EventHandlers[T]
+>;
+
+function extractCallbackArgs(
+ handlerName: T,
+ event: ParsedPaywallEvent,
+): ExtractedArgs {
+ switch (event.id) {
+ case PaywallEventId.DidSelectProduct:
+ return [event.productId] as ExtractedArgs;
+
+ case PaywallEventId.DidStartPurchase:
+ return [event.product] as ExtractedArgs;
+
+ case PaywallEventId.DidFinishPurchase:
+ return [event.purchaseResult, event.product] as ExtractedArgs;
+
+ case PaywallEventId.DidFailPurchase:
+ return [event.error, event.product] as ExtractedArgs;
+
+ case PaywallEventId.DidFinishRestore:
+ return [event.profile] as ExtractedArgs;
+
+ case PaywallEventId.DidFailRestore:
+ case PaywallEventId.DidFailRendering:
+ case PaywallEventId.DidFailLoadingProducts:
+ return [event.error] as ExtractedArgs;
+
+ case PaywallEventId.DidPerformAction:
+ // For DidPerformAction, different handlers need different arguments
+ if (handlerName === 'onUrlPress' || handlerName === 'onCustomAction') {
+ return [event.action.value ?? ''] as ExtractedArgs;
+ }
+ // onCloseButtonPress, onAndroidSystemBack don't take arguments
+ return [] as ExtractedArgs;
+
+ case PaywallEventId.DidFinishWebPaymentNavigation:
+ return [event.product, event.error] as unknown as ExtractedArgs;
+
+ case PaywallEventId.DidAppear:
+ case PaywallEventId.DidDisappear:
+ case PaywallEventId.DidStartRestore:
+ return [] as ExtractedArgs;
}
}