diff --git a/.github/workflows/lint_and_test.yml b/.github/workflows/lint_and_test.yml
index 3af9449..00998d1 100644
--- a/.github/workflows/lint_and_test.yml
+++ b/.github/workflows/lint_and_test.yml
@@ -21,7 +21,7 @@ jobs:
- name: Setup node JS
uses: actions/setup-node@v2
with:
- node-version: 14
+ node-version: 18
registry-url: https://registry.npmjs.org
- name: Setup local environment
@@ -39,7 +39,7 @@ jobs:
- name: Setup node JS
uses: actions/setup-node@v2
with:
- node-version: 14
+ node-version: 18
registry-url: https://registry.npmjs.org
- name: Setup local environment
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b717b1d..289f9b2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,12 @@
# Changelog
+## [Unreleased]
+
+### Changes
+- Upgrade Android SDK dependency to v2.15.2
+- Upgrade iOS SDK dependency to v2.15.1
+
+
## [2.15.0] - 2026-04-08
### Changes
diff --git a/MIGRATION_GUIDE.md b/MIGRATION_GUIDE.md
new file mode 100644
index 0000000..121d736
--- /dev/null
+++ b/MIGRATION_GUIDE.md
@@ -0,0 +1,228 @@
+# Migration Guide from 2.x.x to 3.0.0
+
+Mindbox SDK 3.0 adds support for React Native New Architecture and no longer supports the old React Native architecture.
+
+This guide describes the required changes when migrating from Mindbox SDK 2.x.x to 3.x.x
+## Requirements
+
+- React Native `>=0.76.0`
+- React `>=18.0.0`
+- React Native New Architecture enabled
+- Android min SDK `24`
+- iOS `15.1`
+
+## Breaking Changes
+
+### React Native
+
+- The SDK now requires React Native New Architecture.
+- The minimum supported React Native version has been raised to `>=0.76.0`.
+- The minimum supported React version has been raised to `>=18.0.0`.
+- The SDK now uses TurboModule/codegen integration through `NativeMindboxSdk`.
+- Deprecated JS APIs have been removed:
+ - `getToken`
+ - `updateToken`
+ - `updateNotificationPermissionStatus`
+
+### Android
+
+- The SDK no longer supports the old React Native architecture. If `newArchEnabled=false`, the build fails.
+- `MindboxJsDelivery` is now a Kotlin `object`.
+- `MindboxJsDelivery.Shared.getInstance(context)` has been removed.
+- Client integrations that pass `Context` or `ReactContext` to `MindboxJsDelivery` must be migrated.
+- The minimum Android SDK version has been raised from `21` to `24`.
+
+### iOS
+
+- The SDK no longer supports the old React Native architecture. If `RCT_NEW_ARCH_ENABLED != 1`, the build fails.
+- The minimum iOS version has been raised from `12.0` to `15.1`.
+- Manual calls to `MindboxJsDelivery` from client code are no longer required.
+
+## React Native API Migration
+
+### `getToken`
+
+`getToken` has been removed. Use `getTokens` instead. The method returns push tokens collected by the SDK.
+
+Before:
+
+```typescript
+MindboxSdk.getToken((token: string) => {
+ // Use FCM/APNS token
+})
+```
+
+After:
+
+```typescript
+MindboxSdk.getTokens((tokens: string) => {
+ // Use push tokens returned by the SDK
+})
+```
+
+### `updateToken`
+
+`updateToken` has been removed. Use native Mindbox SDK methods to pass push tokens.
+
+Android:
+
+```kotlin
+
+Mindbox.updatePushToken(context, token)
+```
+
+iOS:
+
+```swift
+Mindbox.shared.apnsTokenUpdate(deviceToken: deviceToken)
+```
+
+### `updateNotificationPermissionStatus`
+
+`updateNotificationPermissionStatus` has been removed. Use `refreshNotificationPermissionStatus` instead.
+
+Before:
+
+```typescript
+MindboxSdk.updateNotificationPermissionStatus(granted)
+```
+
+After:
+
+```typescript
+MindboxSdk.refreshNotificationPermissionStatus()
+```
+
+## Android Migration
+
+Choose one of the options below. Option 1 is recommended.
+
+### Option 1. Simplified Integration With Auto Init
+
+Add the following metadata to the `application` block in `android/app/src/main/AndroidManifest.xml`:
+
+```xml
+
+```
+
+Remove Mindbox client integration code from `MainActivity` and `MainApplication`.
+
+In particular, remove manual code that:
+
+- initializes or stores `MindboxJsDelivery`;
+- calls `MindboxJsDelivery.Shared.getInstance(context)`;
+- passes `ReactContext` to Mindbox push-click handling;
+- manually calls `Mindbox.initPushServices(...)` only for React Native lifecycle integration.
+
+After this change, the SDK handles React Native push-click delivery internally.
+
+### Option 2. Simplified Integration Without AndroidManifest Changes
+
+If you do not want to add `AUTO_INIT_ENABLED` to `AndroidManifest.xml`, remove Mindbox-specific code from `MainActivity` and update `MainApplication`.
+
+Before:
+
+```kotlin
+Mindbox.initPushServices(
+ this,
+ listOf(MindboxFirebase, MindboxHuawei, MindboxRuStore)
+)
+```
+
+After:
+
+```kotlin
+import com.mindboxsdk.initPushServicesForReactNative
+
+Mindbox.initPushServicesForReactNative(
+ this,
+ listOf(MindboxFirebase, MindboxHuawei, MindboxRuStore)
+)
+```
+
+This initializes push services and registers the React Native lifecycle listener required by the SDK.
+
+### Option 3. Manual MainActivity Migration
+
+If you need to keep manual push-click handling in `MainActivity`, update the integration to the new `MindboxJsDelivery` API.
+
+Before:
+
+```kotlin
+private var jsDelivery: MindboxJsDelivery? = null
+
+private fun initializeAndSendIntent(context: ReactContext) {
+ jsDelivery = MindboxJsDelivery.Shared.getInstance(context)
+ jsDelivery?.sendPushClicked(intent)
+}
+```
+
+After:
+
+```kotlin
+class MainActivity : ReactActivity() {
+ private fun handlePushIntent(intent: Intent) {
+ Mindbox.onNewIntent(intent)
+ Mindbox.onPushClicked(applicationContext, intent)
+ MindboxJsDelivery.sendPushClicked(intent)
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ handlePushIntent(intent)
+ }
+
+ override fun onNewIntent(intent: Intent) {
+ super.onNewIntent(intent)
+ handlePushIntent(intent)
+ }
+}
+```
+
+## iOS Migration
+
+Existing iOS integrations can continue to work after enabling React Native New Architecture and updating the minimum iOS version. However, the recommended approach is to simplify `AppDelegate` and let the SDK handle notification delivery.
+
+### Recommended Simplified Integration
+
+Keep only the Mindbox-related code shown below in `AppDelegate`.
+
+All other Mindbox code in `AppDelegate` can be removed, including direct calls to `MindboxJsDelivery`.
+
+Add SDK configuration during application startup:
+
+```swift
+@main
+class AppDelegate: RCTAppDelegate, UNUserNotificationCenterDelegate {
+ override func application(
+ _ application: UIApplication,
+ didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
+ ) -> Bool {
+ ....
+ ....
+ // Set the notification center delegate before configuring Mindbox.
+ // MindboxApp.configure reads the current delegate during setup.
+ UNUserNotificationCenter.current().delegate = self
+ MindboxApp.configure(launchOptions: launchOptions)
+ ....
+ }
+}
+```
+
+Warning: if your app calls `MindboxJsDelivery` or `MindboxJSDelivery` directly, remove that code. Manual calls are no longer required. Push-click and in-app event delivery are handled by the SDK.
+
+## Migration Checklist
+
+- Enable React Native New Architecture.
+- Update React Native to `>=0.76.0`.
+- Update React to `>=18.0.0`.
+- Update Android min SDK to `24`.
+- Update iOS deployment target to `15.1`.
+- Remove usages of `getToken`.
+- Replace `getToken` with `getTokens`.
+- Remove usages of `updateToken`.
+- Replace `updateNotificationPermissionStatus` with `refreshNotificationPermissionStatus`.
+- Migrate Android push-click integration to one of the supported options.
+- Remove direct `MindboxJsDelivery` calls from iOS client code.
diff --git a/MindboxSdk.podspec b/MindboxSdk.podspec
index fdb1db1..83c7337 100644
--- a/MindboxSdk.podspec
+++ b/MindboxSdk.podspec
@@ -10,13 +10,13 @@ Pod::Spec.new do |s|
s.license = package["license"]
s.authors = package["author"]
- s.platforms = { :ios => "12.0" }
+ s.platforms = { :ios => "15.1" }
s.source = { :git => "https://github.com/mindbox-moscow/react-native-sdk/.git", :tag => "#{s.version}" }
s.source_files = "ios/**/*.{h,m,mm,swift}"
- s.dependency "React-Core"
+ install_modules_dependencies(s)
- s.dependency "Mindbox", "2.15.0"
- s.dependency "MindboxNotifications", "2.15.0"
+ s.dependency "Mindbox", "2.15.1"
+ s.dependency "MindboxNotifications", "2.15.1"
end
diff --git a/README.md b/README.md
index 2d8c1e9..e922a77 100644
--- a/README.md
+++ b/README.md
@@ -31,6 +31,55 @@ Initialize the Mindbox SDK in your React Native app. You can find the necessary
Learn how to send events to Mindbox. Different operations and their usage are detailed [here](https://developers.mindbox.ru/docs/integration-actions-react-native).
+### Embedded Blocks
+
+Mark a place in your layout with `MindboxEmbeddedBlock` and the SDK decides what goes into it from
+the admin panel — the app never learns what the content is, and it can change without a release.
+The host owns the size: pass the `height` the block should occupy. A place that ends up without
+content collapses to zero height and hands the space back.
+
+```tsx
+import { MindboxEmbeddedBlock } from 'mindbox-sdk';
+
+
+```
+
+Both outcomes can be customized, the same way as in SwiftUI, Compose and Flutter: `placeholder`
+replaces the stock loading shimmer, and `error` opts into showing a failure instead of collapsing.
+An empty place always collapses — a host cannot fill the space of a block that was never meant to
+be there. `onLoad` and `onFail` report how the load ended.
+
+```tsx
+}
+ error={}
+ onFail={() => setShowStoriesSection(false)}
+/>
+```
+
+How long a block may wait for its content before it gives the place back is `timeoutMs`. Left out,
+it is the SDK's own budget of 30 seconds. The wait is the user's: it is counted only while the
+screen the block stands on is the one being looked at — and since every React Native screen lives
+in the same native window, the block cannot see that for itself. Pass `active` from the navigation
+(`useIsFocused()` in React Navigation), or a block behind a pushed screen will spend its budget on
+a screen nobody is looking at.
+
+```tsx
+
+```
+
+`height` is live: a new value resizes a block already on screen in place — the same content, no
+reload. It has to be positive, though: a block given no space to occupy is never loaded and reports
+no outcome. `timeoutMs` is fixed when the block is created — a new value is ignored with a warning;
+give the component a new `key` to load a block on a new budget.
+
### Push Notifications
Mindbox SDK aids in handling push notifications. It offers configurations and usage instructions, found in the SDK documentation [Android(FCM)](https://developers.mindbox.ru/docs/firebase-send-push-notifications-react-native), [Android(HCM)](https://developers.mindbox.ru/docs/huawei-send-push-notifications-react-native), [IOS](https://developers.mindbox.ru/docs/ios-send-push-notifications-react-native) and [IOS(Rich)](https://developers.mindbox.ru/docs/ios-send-rich-push-react-native).
diff --git a/android/build.gradle b/android/build.gradle
index 605b2b1..1188826 100644
--- a/android/build.gradle
+++ b/android/build.gradle
@@ -1,6 +1,7 @@
buildscript {
- // Buildscript is evaluated before everything else so we can't use getExtOrDefault
- def kotlin_version = rootProject.ext.has('kotlinVersion') ? rootProject.ext.get('kotlinVersion') : project.properties['MindboxSdk_kotlinVersion']
+ ext.safeExtGet = { prop, fallback ->
+ rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback
+ }
repositories {
google()
@@ -8,123 +9,84 @@ buildscript {
}
dependencies {
- classpath 'com.android.tools.build:gradle:3.2.1'
- // noinspection DifferentKotlinGradleVersion
- classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
+ classpath('com.android.tools.build:gradle:8.6.0')
+ classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:${safeExtGet('kotlinVersion', '1.9.22')}")
}
}
apply plugin: 'com.android.library'
apply plugin: 'kotlin-android'
-
-def getExtOrDefault(name) {
- return rootProject.ext.has(name) ? rootProject.ext.get(name) : project.properties['MindboxSdk_' + name]
+// After `com.android.library`: the React plugin wires its codegen tasks from the Android library
+// extension, and applying it first leaves the library without them.
+apply plugin: "com.facebook.react"
+
+def readBooleanGradleProperty = { String name ->
+ def value = project.findProperty(name)
+ if (value == null) {
+ value = rootProject.findProperty(name)
+ }
+ if (value == null) {
+ return null
+ }
+ return value.toString().toBoolean()
}
-def getExtOrIntegerDefault(name) {
- return rootProject.ext.has(name) ? rootProject.ext.get(name) : (project.properties['MindboxSdk_' + name]).toInteger()
+def isNewArchEnabled = readBooleanGradleProperty("newArchEnabled")
+
+if (isNewArchEnabled == false) {
+ throw new GradleException(
+ "${project.name}: Mindbox SDK supports only React Native New Architecture on Android.\n" +
+ "Remove `newArchEnabled=false` from gradle.properties or enable the New Architecture."
+ )
}
android {
- compileSdkVersion getExtOrIntegerDefault('compileSdkVersion')
- buildToolsVersion getExtOrDefault('buildToolsVersion')
+ namespace 'com.mindboxsdk'
+ compileSdkVersion safeExtGet('compileSdkVersion', 35)
+
defaultConfig {
- minSdkVersion 21
- targetSdkVersion getExtOrIntegerDefault('targetSdkVersion')
+ minSdkVersion safeExtGet('minSdkVersion', 24)
+ targetSdkVersion safeExtGet('targetSdkVersion', 35)
versionCode 1
- versionName "1.0"
+ versionName '1.0'
consumerProguardFiles 'consumer-rules.pro'
}
- buildTypes {
- release {
- minifyEnabled false
- }
- }
- lintOptions {
- disable 'GradleCompatible'
- }
compileOptions {
- sourceCompatibility JavaVersion.VERSION_1_8
- targetCompatibility JavaVersion.VERSION_1_8
+ sourceCompatibility JavaVersion.VERSION_17
+ targetCompatibility JavaVersion.VERSION_17
}
-}
-
-repositories {
- mavenCentral()
- google()
- def found = false
- def defaultDir = null
- def androidSourcesName = 'React Native sources'
-
- if (rootProject.ext.has('reactNativeAndroidRoot')) {
- defaultDir = rootProject.ext.get('reactNativeAndroidRoot')
- } else {
- defaultDir = new File(
- projectDir,
- '/../../../node_modules/react-native/android'
- )
+ kotlinOptions {
+ jvmTarget = '17'
}
- if (defaultDir.exists()) {
- maven {
- url defaultDir.toString()
- name androidSourcesName
+ sourceSets {
+ main {
+ java.srcDirs += ["$buildDir/generated/source/codegen/java"]
}
-
- logger.info(":${project.name}:reactNativeAndroidRoot ${defaultDir.canonicalPath}")
- found = true
- } else {
- def parentDir = rootProject.projectDir
-
- 1.upto(5, {
- if (found) return true
- parentDir = parentDir.parentFile
-
- def androidSourcesDir = new File(
- parentDir,
- 'node_modules/react-native'
- )
-
- def androidPrebuiltBinaryDir = new File(
- parentDir,
- 'node_modules/react-native/android'
- )
-
- if (androidPrebuiltBinaryDir.exists()) {
- maven {
- url androidPrebuiltBinaryDir.toString()
- name androidSourcesName
- }
-
- logger.info(":${project.name}:reactNativeAndroidRoot ${androidPrebuiltBinaryDir.canonicalPath}")
- found = true
- } else if (androidSourcesDir.exists()) {
- maven {
- url androidSourcesDir.toString()
- name androidSourcesName
- }
-
- logger.info(":${project.name}:reactNativeAndroidRoot ${androidSourcesDir.canonicalPath}")
- found = true
- }
- })
}
- if (!found) {
- throw new GradleException(
- "${project.name}: unable to locate React Native android sources. " +
- "Ensure you have you installed React Native as a dependency in your project and try again."
- )
+ lintOptions {
+ abortOnError false
}
}
-def kotlin_version = getExtOrDefault('kotlinVersion')
+repositories {
+ mavenCentral()
+ google()
+}
+
+def nativeSdkVersion = safeExtGet('mindboxNativeSdkVersion', '2.15.2')
dependencies {
- // noinspection GradleDynamicVersion
- api 'com.facebook.react:react-native:+'
- implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
- api 'cloud.mindbox:mobile-sdk:2.15.0'
+ implementation 'com.facebook.react:react-native:+'
+ implementation "org.jetbrains.kotlin:kotlin-stdlib:${safeExtGet('kotlinVersion', '1.9.22')}"
+ api "cloud.mindbox:mobile-sdk:$nativeSdkVersion"
+ // The block reads its host screen's lifecycle off the view tree, and this wrapper has to put one
+ // there: React Native's own view lifetime, not the fragment's.
+ implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.8.7"
+ // The SDK keeps mindbox-common an `implementation` dependency, so `@InternalMindboxApi` — the
+ // annotation the embedded block hooks are marked with — is not on the compile classpath without this.
+ compileOnly "cloud.mindbox:mindbox-common:$nativeSdkVersion"
}
diff --git a/android/src/main/java/com/mindboxsdk/MindboxEventEmitter.kt b/android/src/main/java/com/mindboxsdk/MindboxEventEmitter.kt
index 5a6de8e..57a1f42 100644
--- a/android/src/main/java/com/mindboxsdk/MindboxEventEmitter.kt
+++ b/android/src/main/java/com/mindboxsdk/MindboxEventEmitter.kt
@@ -2,22 +2,14 @@ package com.mindboxsdk
import android.app.Activity
import android.app.Application
-import android.content.Context
import android.content.Intent
-import android.util.Log
-import com.facebook.react.ReactApplication
-import com.facebook.react.ReactInstanceManager
import com.facebook.react.bridge.ReactContext
-import com.facebook.react.ReactActivity
import cloud.mindbox.mobile_sdk.Mindbox
import cloud.mindbox.mobile_sdk.logger.Level
-internal class MindboxEventEmitter (
- private val application: Application
+internal class MindboxEventEmitter(
+ private val application: Application,
) : MindboxEventSubscriber {
-
- private var jsDelivery: MindboxJsDelivery? = null
-
override fun onEvent(event: MindboxSdkLifecycleEvent) {
when (event) {
is MindboxSdkLifecycleEvent.NewIntent -> handleNewIntent(event.reactContext, event.intent)
@@ -30,10 +22,10 @@ internal class MindboxEventEmitter (
Mindbox.writeLog("[RN] Handle new intent in event emitter. ", Level.INFO)
Mindbox.onNewIntent(intent)
Mindbox.onPushClicked(context, intent)
- jsDelivery?.sendPushClicked(intent)
+ MindboxJsDelivery.sendPushClicked(intent)
}
- private fun handleActivityCreated(reactContext:ReactContext, activity: Activity) {
+ private fun handleActivityCreated(reactContext: ReactContext, activity: Activity) {
Mindbox.writeLog("[RN] Handle activity created", Level.INFO)
runCatching {
reactContext.let { reactContext ->
@@ -44,12 +36,9 @@ internal class MindboxEventEmitter (
private fun initializeAndSendIntent(context: ReactContext, activity: Activity) {
Mindbox.writeLog("[RN] Initialize MindboxJsDelivery", Level.INFO)
- jsDelivery = MindboxJsDelivery.Shared.getInstance(context)
- val currentActivity = context.currentActivity ?: activity
+ val currentActivity: Activity = context.currentActivity ?: activity
currentActivity.intent?.let { handleNewIntent(context, it) }
}
- private fun handleActivityDestroyed() {
- jsDelivery = null
- }
+ private fun handleActivityDestroyed() {}
}
diff --git a/android/src/main/java/com/mindboxsdk/MindboxEventSubscriber.kt b/android/src/main/java/com/mindboxsdk/MindboxEventSubscriber.kt
index ad7105f..6cc0600 100644
--- a/android/src/main/java/com/mindboxsdk/MindboxEventSubscriber.kt
+++ b/android/src/main/java/com/mindboxsdk/MindboxEventSubscriber.kt
@@ -1,5 +1,5 @@
package com.mindboxsdk
-internal interface MindboxEventSubscriber {
+internal fun interface MindboxEventSubscriber {
fun onEvent(event: MindboxSdkLifecycleEvent)
}
diff --git a/android/src/main/java/com/mindboxsdk/MindboxJsDelivery.kt b/android/src/main/java/com/mindboxsdk/MindboxJsDelivery.kt
index a323107..a40f178 100644
--- a/android/src/main/java/com/mindboxsdk/MindboxJsDelivery.kt
+++ b/android/src/main/java/com/mindboxsdk/MindboxJsDelivery.kt
@@ -2,68 +2,52 @@ package com.mindboxsdk
import android.content.Intent
import android.os.Bundle
-import com.facebook.react.bridge.ReactContext
-import com.facebook.react.modules.core.DeviceEventManagerModule.RCTDeviceEventEmitter
-import org.json.JSONObject
-import kotlin.properties.Delegates
import cloud.mindbox.mobile_sdk.Mindbox
import cloud.mindbox.mobile_sdk.logger.Level
+import kotlin.properties.Delegates
-class MindboxJsDelivery private constructor(private val mReactContext: ReactContext) {
- companion object Shared {
- private var INSTANCE: MindboxJsDelivery? = null
+object MindboxJsDelivery {
private var delayedIntent: Intent? = null
- var hasListeners: Boolean by Delegates.observable(false) { _, _, newValue ->
- if (newValue) {
- delayedIntent?.let {
- it.extras?.let {
- Mindbox.writeLog("[RN] Send push data from delayed ${it}", Level.INFO)
+ internal var hasListeners: Boolean by Delegates.observable(false) { _, _, newValue ->
+ Mindbox.writeLog("[RN][MindboxJsDelivery] hasListeners=$newValue", Level.DEBUG)
+ if (newValue) {
+ delayedIntent?.let { intent ->
+ intent.extras?.let {
+ Mindbox.writeLog("[RN] Send push data from delayed ${it}", Level.INFO)
+ }
+ sendPushClicked(intent)
}
- INSTANCE?.sendPushClicked(it)
}
- }
- delayedIntent = null
+ delayedIntent = null
}
- fun getInstance(reactContext: ReactContext): MindboxJsDelivery? {
- if (INSTANCE == null) {
- synchronized(MindboxJsDelivery::class.java) {
- if (INSTANCE == null) {
- INSTANCE = MindboxJsDelivery(reactContext)
- }
- }
- }
-
- return INSTANCE
+ private fun sendEvent(eventName: String, bundle: Bundle) {
+ Mindbox.writeLog("[RN][MindboxJsDelivery] sendEvent($eventName) push_url=${bundle.getString("push_url")}", Level.INFO)
+ MindboxSdkModule.deliverPushNotificationClickedFromExternal(bundle)
}
- }
- private fun sendEvent(eventName: String, bundle: Bundle) {
- if (mReactContext.hasActiveCatalystInstance()) {
- var payload = JSONObject();
- payload.put("pushUrl", bundle.getString("push_url", ""));
- payload.put("pushPayload", bundle.getString("push_payload", ""));
- Mindbox.writeLog("[RN] Send push data to listener with ${payload.toString()}", Level.INFO)
- mReactContext
- .getJSModule(RCTDeviceEventEmitter::class.java)
- .emit(eventName, payload.toString())
- }
- }
-
- fun sendPushClicked(intent: Intent) {
- if (hasListeners) {
- val bundle = intent.extras
- if (bundle != null) {
- val key = bundle.getString("uniq_push_key")
- if (key != null) {
- sendEvent("pushNotificationClicked", bundle)
+ /**
+ * Sends a push-click intent to JS or delays it until listeners are registered.
+ *
+ * If no listeners are registered, the intent is cached and replayed later. Intents without
+ * `uniq_push_key` are ignored.
+ *
+ * @param intent push-click intent to process
+ */
+ fun sendPushClicked(intent: Intent) {
+ if (hasListeners) {
+ val bundle = intent.extras
+ if (bundle != null) {
+ val key = bundle.getString("uniq_push_key")
+ if (key != null) {
+ sendEvent("pushNotificationClicked", bundle)
+ } else {
+ Mindbox.writeLog("[RN] Push without uniq_push_key — ignored", Level.INFO)
+ }
+ }
} else {
- Mindbox.writeLog("[RN] Push was received without uniq_push_key it is not our push", Level.INFO)
+ delayedIntent = intent
}
- }
- } else {
- delayedIntent = intent
}
- }
}
diff --git a/android/src/main/java/com/mindboxsdk/MindboxSdkLifecycleListener.kt b/android/src/main/java/com/mindboxsdk/MindboxSdkLifecycleListener.kt
index 7d8798c..38d03e1 100644
--- a/android/src/main/java/com/mindboxsdk/MindboxSdkLifecycleListener.kt
+++ b/android/src/main/java/com/mindboxsdk/MindboxSdkLifecycleListener.kt
@@ -5,7 +5,8 @@ import android.app.Application
import android.content.Intent
import android.os.Bundle
import com.facebook.react.ReactApplication
-import com.facebook.react.ReactInstanceManager
+import com.facebook.react.ReactHost
+import com.facebook.react.ReactInstanceEventListener
import com.facebook.react.bridge.ActivityEventListener
import com.facebook.react.bridge.ReactContext
import java.util.concurrent.atomic.AtomicBoolean
@@ -53,6 +54,8 @@ internal class MindboxSdkLifecycleListener private constructor(
}
private var activityEventListener: ActivityEventListener? = null
+ private var reactInstanceEventListener: ReactInstanceEventListener? = null
+ private var reactInstanceEventListenerActivity: Activity? = null
private fun onReactContextAvailable(reactContext: ReactContext, activity: Activity) {
Mindbox.writeLog("[RN] ReactContext ready", Level.INFO)
@@ -60,31 +63,32 @@ internal class MindboxSdkLifecycleListener private constructor(
subscriber.onEvent(MindboxSdkLifecycleEvent.ActivityCreated(reactContext, activity))
}
- private fun registerReactContextListener(
- application: Application,
- onReady: (ReactContext) -> Unit
- ) {
- val reactInstanceManager = getReactInstanceManager()
-
- val wrapperListener = object : ReactInstanceManager.ReactInstanceEventListener {
- private val called = AtomicBoolean(false)
+ private fun registerReactContextListener(activity: Activity, onReady: (ReactContext) -> Unit) {
+ val host = getReactHost(application) ?: run {
+ Mindbox.writeLog(
+ "[RN] registerReactContextListener: ReactHost is null, skip listener.",
+ Level.WARN
+ )
+ return
+ }
+ reactInstanceEventListener?.let { previousListener ->
+ host.removeReactInstanceEventListener(previousListener)
+ }
+ reactInstanceEventListenerActivity = null
+ val listener = object : ReactInstanceEventListener {
override fun onReactContextInitialized(context: ReactContext) {
- if (called.compareAndSet(false, true)) {
- Mindbox.writeLog("[RN] ReactContext initialized (listener)", Level.INFO)
- onReady(context)
+ Mindbox.writeLog("[RN] ReactContext initialized (listener)", Level.INFO)
+ host.removeReactInstanceEventListener(this)
+ if (reactInstanceEventListener === this) {
+ reactInstanceEventListener = null
+ reactInstanceEventListenerActivity = null
}
+ onReady(context)
}
}
-
- reactInstanceManager?.addReactInstanceEventListener(wrapperListener)
- // RN 0.78+ introduced ReactHost.addReactInstanceEventListener(...).
- // Older RN versions (<= 0.74) expose only ReactInstanceManager.addReactInstanceEventListener(...).
- // In New Architecture the ReactInstanceManager listener might not fire
- // To support RN 0.78+ reliably while keeping backward compatibility,
- // we try to register via ReactHost using reflection (no compile-time dependency).
- // If ReactHost API is unavailable (older RN), this call is silently ignored and we rely on
- // the ReactInstanceManager path.
- addReactHostListener(application, wrapperListener)
+ reactInstanceEventListener = listener
+ reactInstanceEventListenerActivity = activity
+ host.addReactInstanceEventListener(listener)
}
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
@@ -92,7 +96,7 @@ internal class MindboxSdkLifecycleListener private constructor(
val hasConsumedReactContext = AtomicBoolean(false)
- registerReactContextListener(application) { reactContext ->
+ registerReactContextListener(activity) { reactContext ->
if (hasConsumedReactContext.compareAndSet(false, true)) {
onReactContextAvailable(reactContext, activity)
}
@@ -100,8 +104,8 @@ internal class MindboxSdkLifecycleListener private constructor(
getReactContext()?.let {
if (hasConsumedReactContext.compareAndSet(false, true)) {
- onReactContextAvailable(it, activity)
Mindbox.writeLog("[RN] ReactContext available (pre-existing)", Level.INFO)
+ onReactContextAvailable(it, activity)
}
}
}
@@ -133,9 +137,14 @@ internal class MindboxSdkLifecycleListener private constructor(
override fun onActivityDestroyed(activity: Activity) {
if (!isMainActivity(activity)) return
+ if (reactInstanceEventListenerActivity === activity) {
+ reactInstanceEventListener?.let { listener ->
+ getReactHost(application)?.removeReactInstanceEventListener(listener)
+ }
+ reactInstanceEventListener = null
+ reactInstanceEventListenerActivity = null
+ }
subscriber.onEvent(MindboxSdkLifecycleEvent.ActivityDestroyed(activity))
- getReactContext()?.removeActivityEventListener(activityEventListener)
- activityEventListener = null
}
override fun onActivityStarted(activity: Activity) {}
@@ -144,68 +153,10 @@ internal class MindboxSdkLifecycleListener private constructor(
override fun onActivityStopped(activity: Activity) {}
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {}
+ private fun getReactHost(application: Application): ReactHost? =
+ (application as? ReactApplication)?.reactHost
- private fun getReactInstanceManager(): ReactInstanceManager? =
- runCatching {
- application.getReactApplication()
- ?.reactNativeHost
- ?.reactInstanceManager
- }.onFailure {
- Mindbox.writeLog("[RN] Bridgeless: ReactInstanceManager unsupported. Fallback to ReactHost", Level.INFO)
- }.getOrNull()
-
- private fun Application.getReactApplication() = this as? ReactApplication
-
- private fun getReactContext(): ReactContext? {
- return getReactInstanceManagerContext() ?: getReactHostContext(application)
- }
-
- private fun getReactInstanceManagerContext(): ReactContext? {
- return getReactInstanceManager()?.currentReactContext
- }
-
- private fun getReactHostContext(application: Application): ReactContext? =
- runCatching {
- // RN 0.78+ moves reactContext from reactNativeHost.reactInstanceManager to reactHost
- val reactApplication = application as ReactApplication
- val getHostMethod = reactApplication.javaClass.getMethod("getReactHost")
- val reactHost = getHostMethod.invoke(reactApplication)
- val getContextMethod = reactHost.javaClass.getMethod("getCurrentReactContext")
- getContextMethod.invoke(reactHost) as? ReactContext
- }.onFailure {
- Mindbox.writeLog("[RN] ReactHost currentReactContext unavailable", Level.INFO)
- }.getOrNull()
-
- private fun addReactHostListener(
- application: Application,
- wrapperListener: ReactInstanceManager.ReactInstanceEventListener
- ) {
- runCatching {
- val reactApplication = application as ReactApplication
-
- val hostClass = Class.forName("com.facebook.react.ReactHost")
- val listenerClass = Class.forName("com.facebook.react.ReactInstanceEventListener")
-
- val addMethod = hostClass.getMethod("addReactInstanceEventListener", listenerClass)
- val getHostMethod = reactApplication.javaClass.getMethod("getReactHost")
- val reactHost = getHostMethod.invoke(reactApplication)
-
- val proxy = java.lang.reflect.Proxy.newProxyInstance(
- listenerClass.classLoader,
- arrayOf(listenerClass)
- ) { _, method, args ->
- if (method.name == "onReactContextInitialized" && args?.size == 1 && args[0] is ReactContext) {
- wrapperListener.onReactContextInitialized(args[0] as ReactContext)
- }
- null
- }
-
- addMethod.invoke(reactHost, proxy)
- Mindbox.writeLog("[RN] success added react context listener for reactHost", Level.INFO)
- }.onFailure {
- Mindbox.writeLog("[RN] failed added react context listener for reactHost ", Level.ERROR)
- }
- }
+ private fun getReactContext(): ReactContext? = getReactHost(application)?.currentReactContext
}
/**
diff --git a/android/src/main/java/com/mindboxsdk/MindboxSdkModule.kt b/android/src/main/java/com/mindboxsdk/MindboxSdkModule.kt
index be9237c..a7100a7 100644
--- a/android/src/main/java/com/mindboxsdk/MindboxSdkModule.kt
+++ b/android/src/main/java/com/mindboxsdk/MindboxSdkModule.kt
@@ -2,11 +2,17 @@ package com.mindboxsdk
import android.app.Activity
import android.content.Context
+import android.os.Bundle
import android.os.Handler
+import com.facebook.react.bridge.Arguments
+import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
-import com.facebook.react.bridge.ReactContextBaseJavaModule
-import com.facebook.react.bridge.ReactMethod
-
+import com.facebook.react.bridge.ReactContext
+import com.facebook.react.bridge.ReadableArray
+import com.facebook.react.bridge.WritableMap
+import com.facebook.react.module.annotations.ReactModule
+import cloud.mindbox.mobile_sdk.Mindbox
+import cloud.mindbox.mobile_sdk.MindboxConfiguration
import cloud.mindbox.mobile_sdk.inapp.presentation.InAppCallback
import cloud.mindbox.mobile_sdk.inapp.presentation.callbacks.ComposableInAppCallback
import cloud.mindbox.mobile_sdk.inapp.presentation.callbacks.CopyPayloadInAppCallback
@@ -14,229 +20,229 @@ import cloud.mindbox.mobile_sdk.inapp.presentation.callbacks.DeepLinkInAppCallba
import cloud.mindbox.mobile_sdk.inapp.presentation.callbacks.EmptyInAppCallback
import cloud.mindbox.mobile_sdk.inapp.presentation.callbacks.LoggingInAppCallback
import cloud.mindbox.mobile_sdk.inapp.presentation.callbacks.UrlInAppCallback
-import cloud.mindbox.mobile_sdk.Mindbox
import cloud.mindbox.mobile_sdk.logger.Level
-import cloud.mindbox.mobile_sdk.MindboxConfiguration
-import com.facebook.react.bridge.Promise
-import com.facebook.react.bridge.WritableMap
-import com.facebook.react.bridge.Arguments
-import com.facebook.react.bridge.ReadableArray
-import com.facebook.react.modules.core.DeviceEventManagerModule
import org.json.JSONObject
-class MindboxSdkModule(private val reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) {
- private var deviceUuidSubscription: String? = null
- private var fmsTokenSubscription: String? = null
- private var getTokensSubscription: String? = null
-
- override fun getName(): String {
- return "MindboxSdk"
- }
-
- @ReactMethod
- fun initialize(payloadString: String, promise: Promise) {
- try {
- val payload = JSONObject(payloadString)
- val context: Context = reactApplicationContext.applicationContext
- val activity: Activity? = reactApplicationContext.currentActivity
-
- if (activity != null && context != null) {
- val configurationBuilder = MindboxConfiguration.Builder(
- context = context,
- domain = payload.optString("domain", "api.mindbox.ru"),
- endpointId = payload.optString("endpointId", "")
- )
+@ReactModule(name = NativeMindboxSdkSpec.NAME)
+class MindboxSdkModule(
+ private val reactContext: ReactApplicationContext
+) : NativeMindboxSdkSpec(reactContext) {
- if (payload.has("subscribeCustomerIfCreated")) {
- configurationBuilder.subscribeCustomerIfCreated(payload.optBoolean("subscribeCustomerIfCreated", false))
- }
- if (payload.has("shouldCreateCustomer")) {
- configurationBuilder.shouldCreateCustomer(payload.optBoolean("shouldCreateCustomer", true))
+ companion object {
+ @Volatile
+ private var activeModule: MindboxSdkModule? = null
+
+ private fun setActiveModule(module: MindboxSdkModule) {
+ activeModule = module
}
- if (payload.has("previousInstallId")) {
- configurationBuilder.setPreviousInstallationId(payload.optString("previousInstallId", ""))
+
+ private fun clearActiveModule(module: MindboxSdkModule) {
+ if (activeModule === module) {
+ activeModule = null
+ }
}
- if (payload.has("previousUuid")) {
- configurationBuilder.setPreviousDeviceUuid(payload.optString("previousUuid", ""))
+
+ internal fun deliverPushNotificationClickedFromExternal(bundle: Bundle) {
+ val module: MindboxSdkModule? = activeModule
+ if (module != null) {
+ module.emitPushFromDelivery(bundle)
+ } else {
+ Mindbox.writeLog("[RN][MindboxSdkModule] deliverPush: no active module, event skipped", Level.WARN)
+ }
}
- val configuration = configurationBuilder.build()
+ }
- val handler = Handler(context.mainLooper)
- handler.post(Runnable {
- Mindbox.init(activity, configuration, listOf())
- })
+ init {
+ setActiveModule(this)
+ }
- promise.resolve(true)
- } else {
- promise.resolve(false)
- }
- } catch (error: Throwable) {
- promise.reject(error)
- }
- }
-
- @ReactMethod
- fun registerCallbacks(
- callbacks: ReadableArray
- ) {
- val cb = mutableListOf()
- for (i in 0 until callbacks.size()) {
- when (val callback = callbacks.getString(i)) {
- "urlInAppCallback" -> {
- cb.add(UrlInAppCallback())
- cb.add(DeepLinkInAppCallback())
- cb.add(LoggingInAppCallback())
- }
+ private var deviceUuidSubscription: String? = null
+ private var getTokensSubscription: String? = null
- "copyPayloadInAppCallback" -> {
- cb.add(CopyPayloadInAppCallback())
- cb.add(LoggingInAppCallback())
+ private fun emitPushFromDelivery(bundle: Bundle) {
+ val payload: WritableMap = Arguments.createMap().apply {
+ putString("pushUrl", bundle.getString("push_url", ""))
+ putString("pushPayload", bundle.getString("push_payload", ""))
}
+ emitOnPushNotificationClicked(payload)
+ }
- "emptyInAppCallback" -> {
- cb.add(EmptyInAppCallback())
+ override fun initialize(payloadString: String, promise: Promise) {
+ try {
+ val payload = JSONObject(payloadString)
+ val context: Context = reactApplicationContext.applicationContext
+ val activity: Activity? = reactApplicationContext.currentActivity
+ if (activity != null) {
+ val configurationBuilder = MindboxConfiguration.Builder(
+ context = context,
+ domain = payload.getString("domain"),
+ endpointId = payload.getString("endpointId")
+ )
+ if (payload.has("subscribeCustomerIfCreated")) {
+ configurationBuilder.subscribeCustomerIfCreated(
+ payload.optBoolean("subscribeCustomerIfCreated", false)
+ )
+ }
+ if (payload.has("shouldCreateCustomer")) {
+ configurationBuilder.shouldCreateCustomer(
+ payload.optBoolean("shouldCreateCustomer", true)
+ )
+ }
+ if (payload.has("previousInstallId")) {
+ configurationBuilder.setPreviousInstallationId(
+ payload.optString("previousInstallId", "")
+ )
+ }
+ if (payload.has("previousUuid")) {
+ configurationBuilder.setPreviousDeviceUuid(
+ payload.optString("previousUuid", "")
+ )
+ }
+ if (payload.has("operationsDomain")) {
+ configurationBuilder.operationsDomain(
+ payload.optString("operationsDomain", "")
+ )
+ }
+ if (payload.has("shouldIncludeVersionCode")) {
+ configurationBuilder.shouldIncludeVersionCode(
+ payload.optBoolean("shouldIncludeVersionCode", true)
+ )
+ }
+ val configuration = configurationBuilder.build()
+ val handler = Handler(context.mainLooper)
+ handler.post {
+ Mindbox.init(activity, configuration, listOf())
+ }
+ promise.resolve(true)
+ } else {
+ promise.resolve(false)
+ }
+ } catch (error: Throwable) {
+ promise.reject(error)
}
+ }
- else -> {
- cb.add(object : InAppCallback {
- override fun onInAppClick(id: String, redirectUrl: String, payload: String) {
- val params = Arguments.createMap().apply {
- putString("id", id)
- putString("redirectUrl", redirectUrl)
- putString("payload", payload)
+ override fun registerCallbacks(callbacks: ReadableArray) {
+ val cb = mutableListOf()
+ for (i in 0 until callbacks.size()) {
+ when (callbacks.getString(i)) {
+ "urlInAppCallback" -> {
+ cb.add(UrlInAppCallback())
+ cb.add(DeepLinkInAppCallback())
+ cb.add(LoggingInAppCallback())
+ }
+ "copyPayloadInAppCallback" -> {
+ cb.add(CopyPayloadInAppCallback())
+ cb.add(LoggingInAppCallback())
+ }
+ "emptyInAppCallback" -> {
+ cb.add(EmptyInAppCallback())
+ }
+ else -> {
+ cb.add(object : InAppCallback {
+ override fun onInAppClick(id: String, redirectUrl: String, payload: String) {
+ val params = Arguments.createMap().apply {
+ putString("id", id)
+ putString("redirectUrl", redirectUrl)
+ putString("payload", payload)
+ }
+ emitOnInAppClick(params)
+ }
+ override fun onInAppDismissed(id: String) {
+ val params = Arguments.createMap().apply {
+ putString("id", id)
+ }
+ emitOnInAppDismiss(params)
+ }
+ })
}
- reactContext.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java).emit("Click", params)
}
+ }
+ Mindbox.registerInAppCallback(ComposableInAppCallback(cb))
+ }
- override fun onInAppDismissed(id: String) {
- val params = Arguments.createMap().apply {
- putString("id", id)
- }
+ override fun getDeviceUUID(promise: Promise) {
+ try {
+ if (deviceUuidSubscription != null) {
+ Mindbox.disposeDeviceUuidSubscription(deviceUuidSubscription!!)
+ }
+ deviceUuidSubscription = Mindbox.subscribeDeviceUuid { deviceUUID ->
+ promise.resolve(deviceUUID)
+ }
+ } catch (error: Throwable) {
+ promise.reject(error)
+ }
+ }
- reactContext.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java).emit("Dismiss", params)
+ override fun getTokens(promise: Promise) {
+ try {
+ if (getTokensSubscription != null) {
+ Mindbox.disposePushTokenSubscription(getTokensSubscription!!)
}
- })
+ getTokensSubscription = Mindbox.subscribePushTokens { tokens ->
+ promise.resolve(tokens)
+ }
+ } catch (error: Throwable) {
+ promise.reject(error)
}
- }
- }
- Mindbox.registerInAppCallback(ComposableInAppCallback(cb))
- }
-
- @ReactMethod
- fun getDeviceUUID(promise: Promise) {
- try {
- if (this.deviceUuidSubscription != null) {
- Mindbox.disposeDeviceUuidSubscription(this.deviceUuidSubscription!!)
- }
-
- this.deviceUuidSubscription = Mindbox.subscribeDeviceUuid {
- deviceUUID -> promise.resolve(deviceUUID)
- }
- } catch (error: Throwable) {
- promise.reject(error)
- }
- }
-
- @ReactMethod
- fun getFMSToken(promise: Promise) {
- try {
- if (this.fmsTokenSubscription != null) {
- Mindbox.disposePushTokenSubscription(this.fmsTokenSubscription!!)
- }
-
- this.fmsTokenSubscription = Mindbox.subscribePushToken {
- fmsToken -> promise.resolve(fmsToken)
- }
- } catch (error: Throwable) {
- promise.reject(error)
- }
- }
-
- @ReactMethod
- fun getTokens(promise: Promise) {
- try {
- if (this.getTokensSubscription != null) {
- Mindbox.disposePushTokenSubscription(this.getTokensSubscription!!)
- }
-
- this.getTokensSubscription = Mindbox.subscribePushTokens {
- tokens -> promise.resolve(tokens)
- }
- } catch (error: Throwable) {
- promise.reject(error)
- }
- }
-
- @ReactMethod
- fun updateFMSToken(token: String, promise: Promise) {
- try {
- //It's stub. Used because deprecate updateToken on RN sdk
- Mindbox.updateNotificationPermissionStatus(reactApplicationContext.applicationContext)
- promise.resolve(true)
- } catch (error: Throwable) {
- promise.reject(error)
- }
- }
-
- @ReactMethod
- fun executeAsyncOperation(operationSystemName: String, operationBody: String, promise: Promise) {
- Mindbox.executeAsyncOperation(reactApplicationContext.applicationContext, operationSystemName, operationBody)
- promise.resolve(true)
- }
-
- @ReactMethod
- fun executeSyncOperation(operationSystemName: String, operationBody: String, promise: Promise) {
- Mindbox.executeSyncOperation(
- context = reactApplicationContext.applicationContext,
- operationSystemName = operationSystemName,
- operationBodyJson = operationBody,
- onSuccess = {
- response -> promise.resolve(response)
- },
- onError = {
- error -> promise.resolve(error.toJson())
- }
- )
- }
-
- @ReactMethod
- fun onPushClickedIsRegistered(isRegistered: Boolean) {
- MindboxJsDelivery.Shared.hasListeners = isRegistered
- }
-
- @ReactMethod
- fun setLogLevel(level: Int) {
- val logLevel : Level = Level.values()[level]
- Mindbox.setLogLevel(logLevel)
- }
-
- @ReactMethod
- fun getSdkVersion(promise: Promise) {
- try {
- promise.resolve(Mindbox.getSdkVersion())
- } catch (error: Throwable) {
- promise.reject(error)
- }
- }
-
- @ReactMethod
- fun pushDelivered(uniqKey: String) {
- Mindbox.onPushReceived(
- context = reactApplicationContext.applicationContext,
- uniqKey = uniqKey,
- )
- }
-
- @ReactMethod
- fun refreshNotificationPermissionStatus() {
- Mindbox.updateNotificationPermissionStatus(
- context = reactApplicationContext.applicationContext,
- )
- }
-
- @ReactMethod
- fun writeNativeLog(message: String, logLevel: Int) {
- val logLevel : Level = Level.values()[logLevel]
- Mindbox.writeLog(message, logLevel)
- }
+ }
+
+ override fun executeAsyncOperation(operationSystemName: String, operationBody: String, promise: Promise) {
+ Mindbox.executeAsyncOperation(
+ reactApplicationContext.applicationContext,
+ operationSystemName,
+ operationBody
+ )
+ promise.resolve(true)
+ }
+
+ override fun executeSyncOperation(operationSystemName: String, operationBody: String, promise: Promise) {
+ Mindbox.executeSyncOperation(
+ context = reactApplicationContext.applicationContext,
+ operationSystemName = operationSystemName,
+ operationBodyJson = operationBody,
+ onSuccess = { response -> promise.resolve(response) },
+ onError = { error -> promise.resolve(error.toJson()) }
+ )
+ }
+
+ override fun onPushClickedIsRegistered(isRegistered: Boolean) {
+ MindboxJsDelivery.hasListeners = isRegistered
+ }
+
+ override fun setLogLevel(level: Double) {
+ val logLevel: Level = Level.values()[level.toInt()]
+ Mindbox.setLogLevel(logLevel)
+ }
+
+ override fun getSdkVersion(promise: Promise) {
+ try {
+ promise.resolve(Mindbox.getSdkVersion())
+ } catch (error: Throwable) {
+ promise.reject(error)
+ }
+ }
+
+ override fun pushDelivered(uniqKey: String) {
+ Mindbox.onPushReceived(
+ context = reactApplicationContext.applicationContext,
+ uniqKey = uniqKey,
+ )
+ }
+
+ override fun refreshNotificationPermissionStatus() {
+ Mindbox.updateNotificationPermissionStatus(
+ context = reactApplicationContext.applicationContext,
+ )
+ }
+
+ override fun writeNativeLog(message: String, logLevel: Double) {
+ val level: Level = Level.values()[logLevel.toInt()]
+ Mindbox.writeLog(message, level)
+ }
+
+ override fun invalidate() {
+ clearActiveModule(this)
+ super.invalidate()
+ }
}
diff --git a/android/src/main/java/com/mindboxsdk/MindboxSdkPackage.kt b/android/src/main/java/com/mindboxsdk/MindboxSdkPackage.kt
index 148b1c1..e9b1e55 100644
--- a/android/src/main/java/com/mindboxsdk/MindboxSdkPackage.kt
+++ b/android/src/main/java/com/mindboxsdk/MindboxSdkPackage.kt
@@ -1,17 +1,34 @@
package com.mindboxsdk
-import com.facebook.react.ReactPackage
+import com.facebook.react.TurboReactPackage
import com.facebook.react.bridge.NativeModule
import com.facebook.react.bridge.ReactApplicationContext
+import com.facebook.react.module.model.ReactModuleInfo
+import com.facebook.react.module.model.ReactModuleInfoProvider
+import com.facebook.react.turbomodule.core.interfaces.TurboModule
import com.facebook.react.uimanager.ViewManager
+import com.mindboxsdk.embedded.MindboxEmbeddedBlockViewManager
+class MindboxSdkPackage : TurboReactPackage() {
+ override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? =
+ if (name == NativeMindboxSdkSpec.NAME) MindboxSdkModule(reactContext) else null
-class MindboxSdkPackage : ReactPackage {
- override fun createNativeModules(reactContext: ReactApplicationContext): List {
- return listOf(MindboxSdkModule(reactContext))
+ override fun getReactModuleInfoProvider(): ReactModuleInfoProvider = ReactModuleInfoProvider {
+ mapOf(
+ NativeMindboxSdkSpec.NAME to ReactModuleInfo(
+ NativeMindboxSdkSpec.NAME,
+ MindboxSdkModule::class.java.name,
+ false,
+ false,
+ false,
+ TurboModule::class.java.isAssignableFrom(MindboxSdkModule::class.java)
+ )
+ )
}
- override fun createViewManagers(reactContext: ReactApplicationContext): List> {
- return emptyList()
- }
+ override fun createNativeModules(reactContext: ReactApplicationContext): List =
+ emptyList()
+
+ override fun createViewManagers(reactContext: ReactApplicationContext): List> =
+ listOf(MindboxEmbeddedBlockViewManager())
}
diff --git a/android/src/main/java/com/mindboxsdk/embedded/MindboxEmbeddedBlockEvents.kt b/android/src/main/java/com/mindboxsdk/embedded/MindboxEmbeddedBlockEvents.kt
new file mode 100644
index 0000000..80442de
--- /dev/null
+++ b/android/src/main/java/com/mindboxsdk/embedded/MindboxEmbeddedBlockEvents.kt
@@ -0,0 +1,53 @@
+package com.mindboxsdk.embedded
+
+import com.facebook.react.bridge.Arguments
+import com.facebook.react.bridge.WritableMap
+import com.facebook.react.uimanager.events.Event
+
+/**
+ * Where the block stands now. A state and not an event: the same value arrives more than once, and the
+ * JS side keeps the last one it knew.
+ */
+internal class AppearanceChangeEvent(
+ surfaceId: Int,
+ viewTag: Int,
+ private val appearance: String,
+) : Event(surfaceId, viewTag) {
+
+ override fun getEventName(): String = EVENT_NAME
+
+ override fun getEventData(): WritableMap = Arguments.createMap().apply {
+ putString("appearance", appearance)
+ }
+
+ companion object {
+ const val EVENT_NAME = "topAppearanceChange"
+ }
+}
+
+/** The content is shown. */
+internal class BlockLoadEvent(surfaceId: Int, viewTag: Int) : Event(surfaceId, viewTag) {
+
+ override fun getEventName(): String = EVENT_NAME
+
+ override fun getEventData(): WritableMap = Arguments.createMap()
+
+ companion object {
+ const val EVENT_NAME = "topBlockLoad"
+ }
+}
+
+/**
+ * The place ended up without content. No payload yet — the reason for the failure is not something the
+ * SDK tells apart today, and when it does it lands in this map.
+ */
+internal class BlockFailEvent(surfaceId: Int, viewTag: Int) : Event(surfaceId, viewTag) {
+
+ override fun getEventName(): String = EVENT_NAME
+
+ override fun getEventData(): WritableMap = Arguments.createMap()
+
+ companion object {
+ const val EVENT_NAME = "topBlockFail"
+ }
+}
diff --git a/android/src/main/java/com/mindboxsdk/embedded/MindboxEmbeddedBlockHostView.kt b/android/src/main/java/com/mindboxsdk/embedded/MindboxEmbeddedBlockHostView.kt
new file mode 100644
index 0000000..67a5391
--- /dev/null
+++ b/android/src/main/java/com/mindboxsdk/embedded/MindboxEmbeddedBlockHostView.kt
@@ -0,0 +1,322 @@
+package com.mindboxsdk.embedded
+
+import android.content.Context
+import android.graphics.Color
+import android.view.View
+import android.widget.FrameLayout
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.LifecycleRegistry
+import androidx.lifecycle.setViewTreeLifecycleOwner
+import cloud.mindbox.mobile_sdk.Mindbox
+import cloud.mindbox.mobile_sdk.annotations.InternalMindboxApi
+import cloud.mindbox.mobile_sdk.embedded.MindboxEmbeddedBlockAppearance
+import cloud.mindbox.mobile_sdk.embedded.MindboxEmbeddedBlockListener
+import cloud.mindbox.mobile_sdk.embedded.MindboxEmbeddedBlockView
+import cloud.mindbox.mobile_sdk.logger.Level
+
+/**
+ * The React Native side of one embedded block: a frame that holds the SDK's own container and turns
+ * its two signals into RN events.
+ *
+ * The block itself is the SDK's `MindboxEmbeddedBlockView`, whole and unchanged — the content
+ * factory, the waiting budget, the page and its bridge stay on the native side, and RN gets a view to
+ * place plus the signals to react to.
+ *
+ * Why a frame around it rather than the block itself: the SDK block takes its place system name in
+ * the constructor, and RN creates a view before it has any props. So the block is built once the
+ * props of the first transaction are all in (see [commitProps]) and lives inside this frame.
+ */
+@OptIn(InternalMindboxApi::class)
+internal class MindboxEmbeddedBlockHostView(context: Context) : FrameLayout(context) {
+
+ /** Native → RN: where the block stands now, as one of the wire words. */
+ var onAppearance: ((String) -> Unit)? = null
+
+ /** Native → RN: how the load ended — `load` or `fail`. */
+ var onOutcome: ((String) -> Unit)? = null
+
+ private var blockView: MindboxEmbeddedBlockView? = null
+ private var placeSystemName: String? = null
+ private var timeoutMs: Long? = null
+ private var hostVisible: Boolean = true
+ private var hasPlaceholder: Boolean = false
+ private var hasErrorView: Boolean = false
+
+ /**
+ * The stand-ins currently handed to the container, kept to tell "the host still draws its own
+ * screen" from "it has just started to".
+ */
+ private var placeholderStandIn: View? = null
+ private var errorStandIn: View? = null
+
+ /**
+ * The lifecycle the block reads as its host screen's.
+ *
+ * The container gives up for good when the lifecycle owner above it is destroyed — the right rule
+ * for a native screen, and the wrong one here. React Native keeps this view across screens while
+ * `react-native-screens` destroys the fragment of a screen that gets covered: the block would hear
+ * its host die, free its page, and come back to a screen it can no longer load anything for. So the
+ * block is told about the lifetime that actually matters — this view's own.
+ */
+ private val hostLifecycleOwner = object : LifecycleOwner {
+ val registry: LifecycleRegistry = LifecycleRegistry(this)
+
+ override val lifecycle: Lifecycle
+ get() = registry
+ }
+
+ init {
+ hostLifecycleOwner.registry.currentState = Lifecycle.State.RESUMED
+ setViewTreeLifecycleOwner(hostLifecycleOwner)
+ }
+
+ /**
+ * Lays the block out again after React Native has stopped listening.
+ *
+ * Yoga owns layout in RN, so the view groups on the way up answer `requestLayout()` with nothing.
+ * The container swaps its own children as the block resolves — the shimmer for the page, the page
+ * for the failure — and every child added after the last layout pass would stay at zero size: the
+ * page loads, reports its content, and nobody ever sees it. So the frame measures and lays itself
+ * out on the next turn of the looper, with the bounds RN gave it.
+ */
+ private val measureAndLayout = Runnable {
+ isLayoutScheduled = false
+ // `forceLayout` and not just `measure`: the container swaps a child while RN is already inside
+ // its own layout pass, and that pass clears the flag `requestLayout` had set — a `measure` with
+ // unchanged specs would then return without measuring anything, and the new child would keep
+ // its zero size for good.
+ forceLayout()
+ measure(
+ MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
+ MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY),
+ )
+ layout(left, top, right, bottom)
+ }
+
+ private var isLayoutScheduled = false
+
+ /** Whether RN has asked for a block at all — the props of the first transaction have landed. */
+ private var isBlockWanted = false
+
+ override fun requestLayout() {
+ super.requestLayout()
+
+ if (isLayoutScheduled || width == 0 || height == 0) {
+ return
+ }
+
+ isLayoutScheduled = true
+ post(measureAndLayout)
+ }
+
+ fun setPlaceSystemName(name: String?) {
+ // Taken exactly as given: the SDK compares names without trimming on every platform, so a
+ // padded name is a name like any other — one that simply never resolves. Filtering it here
+ // would turn that into a block that never settles, which is worse than one that collapses.
+ if (name == placeSystemName) {
+ return
+ }
+
+ // A different place is a different block, and the old one has nothing to hand over. The
+ // wrapper keys the whole component by the place, so this is a safety net and not the usual
+ // path — but a place changed under a live block must not leave the old one running.
+ placeSystemName = name
+ dropBlock()
+ }
+
+ /**
+ * The waiting budget the block is built with, in milliseconds as they came over the wire.
+ *
+ * Zero is the wire word for "the host said nothing" and turns back into the null the SDK
+ * constructor reads as its own default; anything else — a negative included — is handed over as it
+ * is, for the container to sanitize and log. A value that arrives after the block is built goes
+ * nowhere: a running wait cannot be re-budgeted, the JS wrapper both freezes the value and warns,
+ * and this is only the native end of that same rule.
+ */
+ fun setTimeoutMs(timeoutMs: Double) {
+ if (blockView == null) {
+ this.timeoutMs = timeoutMs.takeIf { it != 0.0 }?.toLong()
+ }
+ }
+
+ fun setHostVisible(isHostVisible: Boolean) {
+ if (hostVisible == isHostVisible) {
+ return
+ }
+
+ hostVisible = isHostVisible
+ blockView?.setHostVisible(isHostVisible)
+ }
+
+ fun setHasPlaceholder(hasPlaceholder: Boolean) {
+ if (this.hasPlaceholder == hasPlaceholder) {
+ return
+ }
+
+ this.hasPlaceholder = hasPlaceholder
+ syncStandIns()
+ }
+
+ fun setHasErrorView(hasErrorView: Boolean) {
+ if (this.hasErrorView == hasErrorView) {
+ return
+ }
+
+ this.hasErrorView = hasErrorView
+ syncStandIns()
+ }
+
+ /**
+ * The props of a transaction are all in — the block can be built as soon as there is a frame to
+ * build it in.
+ *
+ * Called from here and nowhere else: `createViewInstance` has no props yet, and a single prop
+ * setter would build a block on the place system name while the stand-in flags were still the
+ * defaults.
+ */
+ fun commitProps() {
+ isBlockWanted = true
+ buildBlockIfPossible()
+ }
+
+ /**
+ * The frame has bounds now, which is what the block was waiting for.
+ *
+ * A block built before them starts its page in a view of zero size: the page lays itself out
+ * against a zero-width viewport, reports content that occupies nothing, and a later resize does
+ * not make it lay out again — the block reports `onLoad` for a feed nobody can see. RN gives a view
+ * its bounds after the props, so the block waits for them.
+ */
+ override fun onSizeChanged(width: Int, height: Int, oldWidth: Int, oldHeight: Int) {
+ super.onSizeChanged(width, height, oldWidth, oldHeight)
+ buildBlockIfPossible()
+ }
+
+ /**
+ * The container hands out its appearance the moment the observer subscribes — and a place with
+ * nothing behind it settles right there — so subscribing happens before the block ever reaches the
+ * window.
+ */
+ private fun buildBlockIfPossible() {
+ val place = placeSystemName ?: return
+ if (blockView != null || !isBlockWanted || width == 0 || height == 0) {
+ return
+ }
+
+ if (place.isEmpty()) {
+ // Built all the same: the SDK settles a nameless place as empty, so the host hears
+ // `collapsed` and `onFail` instead of watching a placeholder that never resolves.
+ Mindbox.writeLog(
+ message = "[EmbeddedBlock] A React Native block was created without a place system name and has nothing to resolve",
+ logLevel = Level.ERROR,
+ )
+ }
+
+ val block = MindboxEmbeddedBlockView(context, place, timeoutMs)
+ blockView = block
+
+ syncStandIns()
+ block.setHostVisible(hostVisible)
+ block.setListener(
+ object : MindboxEmbeddedBlockListener {
+ override fun onLoad(view: MindboxEmbeddedBlockView) {
+ onOutcome?.invoke(OUTCOME_LOAD)
+ }
+
+ override fun onFail(view: MindboxEmbeddedBlockView) {
+ onOutcome?.invoke(OUTCOME_FAIL)
+ }
+ },
+ )
+ block.setAppearanceObserver { appearance -> onAppearance?.invoke(nameOf(appearance)) }
+
+ // Last: attaching to the window is what starts the content, and by now everything that has an
+ // opinion about it has been said.
+ addView(block, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT))
+ }
+
+ /**
+ * The RN view is gone, so the block's screen is gone with it. Waiting for the host Activity to be
+ * destroyed instead would keep a page loading for a screen nobody can see.
+ */
+ fun release() {
+ dropBlock()
+ onAppearance = null
+ onOutcome = null
+ // Now the host screen really is gone, and a block that outlived this call — one the platform
+ // still holds a reference to — has to hear it.
+ hostLifecycleOwner.registry.currentState = Lifecycle.State.DESTROYED
+ }
+
+ private fun dropBlock() {
+ val block = blockView ?: return
+
+ blockView = null
+ placeholderStandIn = null
+ errorStandIn = null
+ block.setAppearanceObserver(null)
+ block.setListener(null)
+ block.release()
+ removeView(block)
+ }
+
+ /**
+ * Puts an empty view where the host draws its own screen — the same arrangement the Compose and
+ * Flutter wrappers use for a slot they cannot hand over directly.
+ *
+ * RN children are real Android views, but the ones above this block belong to Fabric: it mounts
+ * them, and Yoga lays them out. Handing them to the container would take them out of both. So the
+ * container is not given the screen: it is given the fact that the place is taken. That is all it
+ * needs — its own placeholder is held back, and a failed block keeps its height instead of
+ * collapsing. What is actually drawn there is an RN overlay above this frame.
+ */
+ private fun syncStandIns() {
+ val block = blockView ?: return
+
+ if (hasPlaceholder) {
+ if (placeholderStandIn == null) {
+ placeholderStandIn = makeStandIn()
+ block.setPlaceholderView(placeholderStandIn)
+ }
+ } else if (placeholderStandIn != null) {
+ placeholderStandIn = null
+ block.setPlaceholderView(null)
+ }
+
+ if (hasErrorView) {
+ if (errorStandIn == null) {
+ errorStandIn = makeStandIn()
+ block.setErrorView(errorStandIn)
+ }
+ } else if (errorStandIn != null) {
+ errorStandIn = null
+ block.setErrorView(null)
+ }
+ }
+
+ private fun makeStandIn(): View = View(context).apply {
+ setBackgroundColor(Color.TRANSPARENT)
+ // The stand-in is a placeholder for space, not for touches: what the host drew over it is an
+ // RN view, and it is RN that has to hear the taps on it.
+ isClickable = false
+ isFocusable = false
+ }
+
+ private companion object {
+
+ const val OUTCOME_LOAD = "load"
+ const val OUTCOME_FAIL = "fail"
+
+ /**
+ * Spelled out rather than taken from the enum name: the wire word is a contract with the JS
+ * side, and renaming a case in the SDK must not quietly change it.
+ */
+ fun nameOf(appearance: MindboxEmbeddedBlockAppearance): String = when (appearance) {
+ MindboxEmbeddedBlockAppearance.PLACEHOLDER -> "placeholder"
+ MindboxEmbeddedBlockAppearance.CONTENT -> "content"
+ MindboxEmbeddedBlockAppearance.ERROR -> "error"
+ MindboxEmbeddedBlockAppearance.COLLAPSED -> "collapsed"
+ }
+ }
+}
diff --git a/android/src/main/java/com/mindboxsdk/embedded/MindboxEmbeddedBlockViewManager.kt b/android/src/main/java/com/mindboxsdk/embedded/MindboxEmbeddedBlockViewManager.kt
new file mode 100644
index 0000000..85f032f
--- /dev/null
+++ b/android/src/main/java/com/mindboxsdk/embedded/MindboxEmbeddedBlockViewManager.kt
@@ -0,0 +1,109 @@
+package com.mindboxsdk.embedded
+
+import com.facebook.react.bridge.ReactContext
+import com.facebook.react.module.annotations.ReactModule
+import com.facebook.react.uimanager.SimpleViewManager
+import com.facebook.react.uimanager.ThemedReactContext
+import com.facebook.react.uimanager.UIManagerHelper
+import com.facebook.react.uimanager.ViewManagerDelegate
+import com.facebook.react.uimanager.events.Event
+import com.facebook.react.viewmanagers.MindboxEmbeddedBlockViewManagerDelegate
+import com.facebook.react.viewmanagers.MindboxEmbeddedBlockViewManagerInterface
+
+/** The manager of the embedded block component: props in, the block's two signals out. */
+@ReactModule(name = MindboxEmbeddedBlockViewManager.NAME)
+internal class MindboxEmbeddedBlockViewManager :
+ SimpleViewManager(),
+ MindboxEmbeddedBlockViewManagerInterface {
+
+ private val managerDelegate = MindboxEmbeddedBlockViewManagerDelegate(this)
+
+ override fun getDelegate(): ViewManagerDelegate = managerDelegate
+
+ override fun getName(): String = NAME
+
+ override fun createViewInstance(context: ThemedReactContext): MindboxEmbeddedBlockHostView =
+ MindboxEmbeddedBlockHostView(context)
+
+ override fun addEventEmitters(reactContext: ThemedReactContext, view: MindboxEmbeddedBlockHostView) {
+ super.addEventEmitters(reactContext, view)
+ view.onAppearance = { appearance ->
+ dispatch(view) { surfaceId, tag -> AppearanceChangeEvent(surfaceId, tag, appearance) }
+ }
+ view.onOutcome = { outcome ->
+ dispatch(view) { surfaceId, tag ->
+ if (outcome == OUTCOME_LOAD) BlockLoadEvent(surfaceId, tag) else BlockFailEvent(surfaceId, tag)
+ }
+ }
+ }
+
+ /**
+ * The block is built here and not in a prop setter: this is the first moment every prop of the
+ * transaction is in, and the container needs the place system name and the stand-in flags together.
+ */
+ override fun onAfterUpdateTransaction(view: MindboxEmbeddedBlockHostView) {
+ super.onAfterUpdateTransaction(view)
+ view.commitProps()
+ }
+
+ override fun onDropViewInstance(view: MindboxEmbeddedBlockHostView) {
+ view.release()
+ super.onDropViewInstance(view)
+ }
+
+ /**
+ * Never recycled. Fabric would hand this frame to another place, and the SDK block inside it cannot
+ * be revived — `release()` is one way. Creating the block with the view and killing it with the view
+ * is what keeps the lifecycle here simple.
+ */
+ override fun prepareToRecycleView(
+ reactContext: ThemedReactContext,
+ view: MindboxEmbeddedBlockHostView,
+ ): MindboxEmbeddedBlockHostView? = null
+
+ override fun setPlaceSystemName(view: MindboxEmbeddedBlockHostView, value: String?) {
+ view.setPlaceSystemName(value)
+ }
+
+ /**
+ * Read and ignored: on Android the block is a frame sized by its parent, and here that parent is
+ * RN — the view is laid out to the height the style gives it. The prop exists because iOS needs it.
+ */
+ override fun setBlockHeight(view: MindboxEmbeddedBlockHostView, value: Double) = Unit
+
+ override fun setTimeoutMs(view: MindboxEmbeddedBlockHostView, value: Double) {
+ view.setTimeoutMs(value)
+ }
+
+ override fun setHasPlaceholder(view: MindboxEmbeddedBlockHostView, value: Boolean) {
+ view.setHasPlaceholder(value)
+ }
+
+ override fun setHasErrorView(view: MindboxEmbeddedBlockHostView, value: Boolean) {
+ view.setHasErrorView(value)
+ }
+
+ override fun setHostVisible(view: MindboxEmbeddedBlockHostView, value: Boolean) {
+ view.setHostVisible(value)
+ }
+
+ override fun getExportedCustomDirectEventTypeConstants(): MutableMap = mutableMapOf(
+ AppearanceChangeEvent.EVENT_NAME to mutableMapOf("registrationName" to "onAppearanceChange"),
+ BlockLoadEvent.EVENT_NAME to mutableMapOf("registrationName" to "onBlockLoad"),
+ BlockFailEvent.EVENT_NAME to mutableMapOf("registrationName" to "onBlockFail"),
+ )
+
+ private inline fun dispatch(
+ view: MindboxEmbeddedBlockHostView,
+ event: (surfaceId: Int, viewTag: Int) -> Event<*>,
+ ) {
+ val reactContext = view.context as? ReactContext ?: return
+ val dispatcher = UIManagerHelper.getEventDispatcherForReactTag(reactContext, view.id) ?: return
+ dispatcher.dispatchEvent(event(UIManagerHelper.getSurfaceId(view), view.id))
+ }
+
+ internal companion object {
+ const val NAME = "MindboxEmbeddedBlockView"
+ const val OUTCOME_LOAD = "load"
+ }
+}
diff --git a/babel.config.js b/babel.config.js
index cf1f9fb..3e0218e 100644
--- a/babel.config.js
+++ b/babel.config.js
@@ -1,3 +1,3 @@
module.exports = {
- presets: ['module:metro-react-native-babel-preset'],
+ presets: ['module:@react-native/babel-preset'],
}
diff --git a/example/exampleApp/android/app/build.gradle b/example/exampleApp/android/app/build.gradle
index c9adc89..f5a2b19 100644
--- a/example/exampleApp/android/app/build.gradle
+++ b/example/exampleApp/android/app/build.gradle
@@ -1,14 +1,14 @@
apply plugin: "com.android.application"
apply plugin: "org.jetbrains.kotlin.android"
apply plugin: "com.facebook.react"
-apply plugin: 'com.google.gms.google-services'
-apply plugin: 'kotlin-android'
-apply plugin: 'com.huawei.agconnect'
-
+apply plugin: "com.google.gms.google-services"
+apply plugin: "kotlin-android"
+apply plugin: "com.huawei.agconnect"
react {
-
+ autolinkLibrariesWithApp()
}
+
def enableProguardInReleaseBuilds = false
def jscFlavor = 'org.webkit:android-jsc:+'
@@ -47,9 +47,6 @@ android {
dependencies {
implementation("com.facebook.react:react-android")
- implementation "com.facebook.react:react-native:0.74.0"
-
- // Integration of Mindbox SDK and necessary Firebase and Huawei services for mobile push notifications functionality
implementation platform('com.google.firebase:firebase-bom:29.3.1')
implementation 'com.google.firebase:firebase-analytics-ktx'
implementation 'com.google.firebase:firebase-messaging-ktx'
@@ -58,8 +55,6 @@ dependencies {
implementation 'com.huawei.hms:push:6.7.0.300'
implementation 'cloud.mindbox:mindbox-rustore'
implementation 'ru.rustore.sdk:pushclient:6.5.1'
-
- //gson
implementation 'com.google.code.gson:gson:2.8.8'
if (hermesEnabled.toBoolean()) {
@@ -68,6 +63,3 @@ dependencies {
implementation jscFlavor
}
}
-
-apply from: file("../../node_modules/@react-native-community/cli-platform-android/native_modules.gradle"); applyNativeModulesAppBuildGradle(project)
-
diff --git a/example/exampleApp/android/app/src/main/AndroidManifest.xml b/example/exampleApp/android/app/src/main/AndroidManifest.xml
index 5d112d5..a6fe1df 100644
--- a/example/exampleApp/android/app/src/main/AndroidManifest.xml
+++ b/example/exampleApp/android/app/src/main/AndroidManifest.xml
@@ -48,6 +48,6 @@
+ android:value="true" />
diff --git a/example/exampleApp/android/app/src/main/java/com/exampleapp/MainActivity.kt b/example/exampleApp/android/app/src/main/java/com/exampleapp/MainActivity.kt
index 790922f..cad6d9c 100644
--- a/example/exampleApp/android/app/src/main/java/com/exampleapp/MainActivity.kt
+++ b/example/exampleApp/android/app/src/main/java/com/exampleapp/MainActivity.kt
@@ -1,68 +1,28 @@
package com.exampleapp
-import android.content.Context
import android.content.Intent
import android.os.Bundle
import cloud.mindbox.mobile_sdk.Mindbox
+import cloud.mindbox.mobile_sdk.logger.Level
import com.facebook.react.ReactActivity
import com.facebook.react.ReactActivityDelegate
-import com.facebook.react.ReactInstanceManager
-import com.facebook.react.bridge.ReactContext
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled
import com.facebook.react.defaults.DefaultReactActivityDelegate
import com.mindboxsdk.MindboxJsDelivery
class MainActivity : ReactActivity() {
- private var jsDelivery: MindboxJsDelivery? = null
+
override fun getMainComponentName(): String = "exampleApp"
override fun createReactActivityDelegate(): ReactActivityDelegate =
DefaultReactActivityDelegate(this, mainComponentName, fabricEnabled)
- // Initializes MindboxJsDelivery and sends the current intent to React Native
- // https://developers.mindbox.ru/docs/flutter-push-navigation-react-native
- private fun initializeAndSentIntent(context: ReactContext) {
- jsDelivery = MindboxJsDelivery.Shared.getInstance(context)
- if (context.hasCurrentActivity()) {
- sendIntent(context, context.getCurrentActivity()!!.getIntent())
- } else {
- sendIntent(context, this.getIntent())
- }
- }
-
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
- val reactInstanceManager = getReactNativeHost().getReactInstanceManager();
- val reactContext = reactInstanceManager.getCurrentReactContext();
-
- // Initialize and send intent if React context is already available
- // https://developers.mindbox.ru/docs/flutter-push-navigation-react-native
- if (reactContext != null) {
- initializeAndSentIntent(reactContext);
+ if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) {
+ Mindbox.writeLog("[RN][exampleApp] New arch enabled", Level.DEBUG)
} else {
- // Add listener to initialize and send intent once React context is initialized
- reactInstanceManager.addReactInstanceEventListener(object :
- ReactInstanceManager.ReactInstanceEventListener {
- override fun onReactContextInitialized(context: ReactContext) {
- initializeAndSentIntent(context)
- reactInstanceManager.removeReactInstanceEventListener(this)
- }
- })
+ Mindbox.writeLog("[RN][exampleApp] Old architecture", Level.DEBUG)
}
}
-
- // Handles new intents received by the activity
- override fun onNewIntent(intent: Intent) {
- super.onNewIntent(intent)
- sendIntent(this, intent)
- }
-
- // Sends the received intent to Mindbox and React Native
- // https://developers.mindbox.ru/docs/android-get-click-react-native
- private fun sendIntent(context: Context, intent: Intent) {
- Mindbox.onNewIntent(intent)
- //send click action
- Mindbox.onPushClicked(context, intent)
- jsDelivery?.sendPushClicked(intent);
- }
}
diff --git a/example/exampleApp/android/app/src/main/java/com/exampleapp/MainApplication.kt b/example/exampleApp/android/app/src/main/java/com/exampleapp/MainApplication.kt
index 7436ac3..f4d306f 100644
--- a/example/exampleApp/android/app/src/main/java/com/exampleapp/MainApplication.kt
+++ b/example/exampleApp/android/app/src/main/java/com/exampleapp/MainApplication.kt
@@ -1,78 +1,52 @@
package com.exampleapp
import android.app.Application
-import android.content.Context
-import android.content.SharedPreferences
-import android.os.Handler
-import android.os.Looper
-import android.util.Log
+import cloud.mindbox.mobile_sdk.Mindbox
import cloud.mindbox.mobile_sdk.pushes.MindboxRemoteMessage
import cloud.mindbox.mindbox_firebase.MindboxFirebase
import cloud.mindbox.mindbox_huawei.MindboxHuawei
-import cloud.mindbox.mobile_sdk.Mindbox
+import cloud.mindbox.mindbox_rustore.MindboxRuStore
import com.facebook.react.PackageList
import com.facebook.react.ReactApplication
-import com.facebook.react.ReactInstanceManager
-import com.facebook.react.ReactNativeHost
+import com.facebook.react.ReactHost
import com.facebook.react.ReactPackage
-import com.facebook.soloader.SoLoader
-import com.exampleapp.NotificationPackage
-import com.facebook.react.modules.core.DeviceEventManagerModule
-import com.google.gson.Gson
-import com.google.gson.reflect.TypeToken
-import cloud.mindbox.mindbox_rustore.MindboxRuStore
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.load
import com.facebook.react.defaults.DefaultReactHost.getDefaultReactHost
import com.facebook.react.defaults.DefaultReactNativeHost
+import com.facebook.react.soloader.OpenSourceMergedSoMapping
+import com.facebook.soloader.SoLoader
class MainApplication : Application(), ReactApplication {
- override val reactNativeHost: ReactNativeHost =
- object : ReactNativeHost(this) {
- override fun getPackages(): List =
- PackageList(this).packages.apply {
- add(NotificationPackage())
- }
- override fun getJSMainModuleName(): String = "index"
- override fun getUseDeveloperSupport(): Boolean = BuildConfig.DEBUG
+ override val reactNativeHost: DefaultReactNativeHost =
+ object : DefaultReactNativeHost(this) {
+ override fun getPackages(): List =
+ PackageList(this).packages.apply {
+ add(NotificationPackage())
}
- override fun onCreate() {
- super.onCreate()
- //The fifth step of https://developers.mindbox.ru/docs/firebase-send-push-notifications-react-native
- Mindbox.initPushServices(this, listOf(MindboxFirebase, MindboxHuawei, MindboxRuStore))
- SoLoader.init(this, false)
- if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) {
- // If you opted-in for the New Architecture, we load the native entry point for this app.
- load()
- }
- }
+ override fun getJSMainModuleName(): String = "index"
+
+ override fun getUseDeveloperSupport(): Boolean = BuildConfig.DEBUG
- private val gson = Gson()
+ override val isNewArchEnabled: Boolean = BuildConfig.IS_NEW_ARCHITECTURE_ENABLED
- fun saveNotification(message: MindboxRemoteMessage) {
- val sharedPreferences = getSharedPreferences("notifications", Context.MODE_PRIVATE)
- val editor = sharedPreferences.edit()
- val notificationsJson = sharedPreferences.getString("notifications", "[]")
- val type = object : TypeToken>() {}.type
- val notifications: MutableList = gson.fromJson(notificationsJson, type)
- notifications.add(gson.toJson(message))
- editor.putString("notifications", gson.toJson(notifications))
- editor.apply()
- notifyJS()
+ override val isHermesEnabled: Boolean = BuildConfig.IS_HERMES_ENABLED
}
- private fun notifyJS() {
- val handler = Handler(Looper.getMainLooper())
- handler.post {
- try {
- val reactInstanceManager = reactNativeHost.reactInstanceManager
- reactInstanceManager.currentReactContext
- ?.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
- ?.emit("newNotification", null)
- } catch (e: Exception) {
- Log.e("MainApplication", "Error notifying React Native", e)
- }
- }
+ override val reactHost: ReactHost
+ get() = getDefaultReactHost(applicationContext, reactNativeHost)
+
+ override fun onCreate() {
+ super.onCreate()
+ SoLoader.init(this, OpenSourceMergedSoMapping)
+ if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) {
+ load()
}
+ }
+
+ fun saveNotification(message: MindboxRemoteMessage) {
+ NotificationStorage.saveNotification(this, message)
+ NotificationModule.emitNotificationCenterUpdatedFromExternal()
+ }
}
diff --git a/example/exampleApp/android/app/src/main/java/com/exampleapp/NotificationModule.kt b/example/exampleApp/android/app/src/main/java/com/exampleapp/NotificationModule.kt
index 10bc93c..a64a3e4 100644
--- a/example/exampleApp/android/app/src/main/java/com/exampleapp/NotificationModule.kt
+++ b/example/exampleApp/android/app/src/main/java/com/exampleapp/NotificationModule.kt
@@ -1,49 +1,57 @@
package com.exampleapp
-import android.content.Context
-import android.content.SharedPreferences
+import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
-import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
+import com.facebook.react.module.annotations.ReactModule
-class NotificationModule(reactContext: ReactApplicationContext) :
- ReactContextBaseJavaModule(reactContext) {
+@ReactModule(name = NativeNotificationModuleSpec.NAME)
+class NotificationModule(
+ private val reactContext: ReactApplicationContext
+) : NativeNotificationModuleSpec(reactContext) {
- private val sharedPreferences: SharedPreferences =
- reactContext.getSharedPreferences("notifications", Context.MODE_PRIVATE)
+ companion object {
+ @Volatile
+ private var activeModule: NotificationModule? = null
- override fun getName(): String {
- return "NotificationModule"
- }
-
- @ReactMethod
- fun addListener(eventName: String?) {
+ fun emitNotificationCenterUpdatedFromExternal() {
+ activeModule?.emitNotificationCenterUpdated()
+ }
}
- @ReactMethod
- fun removeListeners(count: Integer?) {
+ init {
+ activeModule = this
}
@ReactMethod
- fun getNotifications(promise: Promise) {
+ override fun getNotifications(promise: Promise) {
try {
- val notificationsJson = sharedPreferences.getString("notifications", "[]")
+ val notificationsJson: String = NotificationStorage.getNotificationsJson(reactContext)
promise.resolve(notificationsJson)
- } catch (e: Exception) {
- promise.reject("Error", e)
+ } catch (error: Throwable) {
+ promise.reject("Error", error)
}
}
@ReactMethod
- fun clearNotifications(promise: Promise) {
+ override fun clearNotifications(promise: Promise) {
try {
- val editor = sharedPreferences.edit()
- editor.putString("notifications", "[]")
- editor.apply()
+ NotificationStorage.clearNotifications(reactContext)
promise.resolve(null)
- } catch (e: Exception) {
- promise.reject("Error", e)
+ } catch (error: Throwable) {
+ promise.reject("Error", error)
}
}
+
+ override fun invalidate() {
+ if (activeModule === this) {
+ activeModule = null
+ }
+ super.invalidate()
+ }
+
+ private fun emitNotificationCenterUpdated() {
+ emitOnNotificationCenterUpdated(Arguments.createMap())
+ }
}
diff --git a/example/exampleApp/android/app/src/main/java/com/exampleapp/NotificationPackage.kt b/example/exampleApp/android/app/src/main/java/com/exampleapp/NotificationPackage.kt
index 020a3a8..ce7aa2a 100644
--- a/example/exampleApp/android/app/src/main/java/com/exampleapp/NotificationPackage.kt
+++ b/example/exampleApp/android/app/src/main/java/com/exampleapp/NotificationPackage.kt
@@ -1,17 +1,35 @@
package com.exampleapp
-import com.facebook.react.ReactPackage
+import com.facebook.react.TurboReactPackage
import com.facebook.react.bridge.NativeModule
import com.facebook.react.bridge.ReactApplicationContext
-import com.facebook.react.uimanager.ViewManager
+import com.facebook.react.module.model.ReactModuleInfo
+import com.facebook.react.module.model.ReactModuleInfoProvider
+import com.facebook.react.turbomodule.core.interfaces.TurboModule
-class NotificationPackage : ReactPackage {
+class NotificationPackage : TurboReactPackage() {
- override fun createViewManagers(reactContext: ReactApplicationContext): List> {
- return emptyList()
+ override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? {
+ return if (name == NativeNotificationModuleSpec.NAME) {
+ NotificationModule(reactContext)
+ } else {
+ null
+ }
}
- override fun createNativeModules(reactContext: ReactApplicationContext): List {
- return listOf(NotificationModule(reactContext))
+ override fun getReactModuleInfoProvider(): ReactModuleInfoProvider {
+ return ReactModuleInfoProvider {
+ mapOf(
+ NativeNotificationModuleSpec.NAME to ReactModuleInfo(
+ NativeNotificationModuleSpec.NAME,
+ NativeNotificationModuleSpec.NAME,
+ false,
+ false,
+ false,
+ false,
+ TurboModule::class.java.isAssignableFrom(NotificationModule::class.java)
+ )
+ )
+ }
}
}
diff --git a/example/exampleApp/android/app/src/main/java/com/exampleapp/NotificationStorage.kt b/example/exampleApp/android/app/src/main/java/com/exampleapp/NotificationStorage.kt
new file mode 100644
index 0000000..776bf72
--- /dev/null
+++ b/example/exampleApp/android/app/src/main/java/com/exampleapp/NotificationStorage.kt
@@ -0,0 +1,47 @@
+package com.exampleapp
+
+import android.content.Context
+import cloud.mindbox.mobile_sdk.pushes.MindboxRemoteMessage
+import com.google.gson.Gson
+import com.google.gson.reflect.TypeToken
+import java.lang.reflect.Type
+
+object NotificationStorage {
+ private const val PREFERENCES_NAME: String = "notifications"
+ private const val NOTIFICATIONS_KEY: String = "notifications"
+ private const val EMPTY_NOTIFICATIONS_JSON: String = "[]"
+ private val gson: Gson = Gson()
+ private val notificationListType: Type = object : TypeToken>() {}.type
+
+ @Synchronized
+ fun saveNotification(context: Context, message: MindboxRemoteMessage) {
+ val notifications: MutableList = readNotifications(context).toMutableList()
+ notifications.add(gson.toJson(message))
+ context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE)
+ .edit()
+ .putString(NOTIFICATIONS_KEY, gson.toJson(notifications))
+ .apply()
+ }
+
+ @Synchronized
+ fun getNotificationsJson(context: Context): String {
+ return context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE)
+ .getString(NOTIFICATIONS_KEY, EMPTY_NOTIFICATIONS_JSON)
+ ?: EMPTY_NOTIFICATIONS_JSON
+ }
+
+ @Synchronized
+ fun clearNotifications(context: Context) {
+ context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE)
+ .edit()
+ .putString(NOTIFICATIONS_KEY, EMPTY_NOTIFICATIONS_JSON)
+ .apply()
+ }
+
+ private fun readNotifications(context: Context): List {
+ val notificationsJson: String = getNotificationsJson(context)
+ return runCatching {
+ gson.fromJson>(notificationsJson, notificationListType)
+ }.getOrNull() ?: emptyList()
+ }
+}
diff --git a/example/exampleApp/android/build.gradle b/example/exampleApp/android/build.gradle
index d2f118c..2bcf733 100644
--- a/example/exampleApp/android/build.gradle
+++ b/example/exampleApp/android/build.gradle
@@ -1,46 +1,43 @@
buildscript {
ext {
- buildToolsVersion = "34.0.0"
- minSdkVersion = 23
+ buildToolsVersion = "35.0.0"
+ minSdkVersion = 24
compileSdkVersion = 35
targetSdkVersion = 35
ndkVersion = "26.1.10909125"
- kotlinVersion = "1.8.0"
- cmakeVersion = "3.22.1"
+ kotlinVersion = "1.9.24"
}
repositories {
google()
mavenCentral()
maven { url 'https://developer.huawei.com/repo/' }
- maven {url 'https://artifactory-external.vkpartner.ru/artifactory/maven'}
+ maven { url 'https://artifactory-external.vkpartner.ru/artifactory/maven' }
}
dependencies {
classpath("com.android.tools.build:gradle:8.6.0")
- classpath("com.facebook.react:react-native-gradle-plugin:7.5")
- classpath("org.jetbrains.kotlin:kotlin-gradle-plugin")
- classpath 'com.google.gms:google-services:4.3.14'
- classpath "com.huawei.agconnect:agcp:1.8.0.300"
+ classpath("com.facebook.react:react-native-gradle-plugin")
+ classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion")
+ classpath("com.google.gms:google-services:4.3.14")
+ classpath("com.huawei.agconnect:agcp:1.8.0.300")
}
}
+
allprojects {
repositories {
mavenCentral()
mavenLocal()
maven {
- // All of React Native (JS, Obj-C sources, Android binaries) is installed from npm
url("$rootDir/../node_modules/react-native/android")
}
maven {
- // Android JSC is installed from npm
url("$rootDir/../node_modules/jsc-android/dist")
}
google()
- maven { url 'https://www.jitpack.io' }
+ maven { url "https://www.jitpack.io" }
maven { url "https://plugins.gradle.org/m2/" }
- maven { url 'https://developer.huawei.com/repo/' }
- maven {url 'https://artifactory-external.vkpartner.ru/artifactory/maven'}
+ maven { url "https://developer.huawei.com/repo/" }
+ maven { url "https://artifactory-external.vkpartner.ru/artifactory/maven" }
}
-
}
apply plugin: "com.facebook.react.rootproject"
diff --git a/example/exampleApp/android/gradle.properties b/example/exampleApp/android/gradle.properties
index 1351076..6a9a7a2 100644
--- a/example/exampleApp/android/gradle.properties
+++ b/example/exampleApp/android/gradle.properties
@@ -26,7 +26,7 @@ android.enableJetifier=true
# Use this property to specify which architecture you want to build.
# You can also override it from the CLI using
-# ./gradlew -PreactNativeArchitectures=x86_64
+# ./gradlew -PreactNativeArchitectures=x86_64
reactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64
# Use this property to enable support to the new architecture.
@@ -34,7 +34,7 @@ reactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64
# your application. You should enable this flag either if you want
# to write custom TurboModules/Fabric components OR use libraries that
# are providing them.
-newArchEnabled=false
+newArchEnabled=true
# Use this property to enable or disable the Hermes JS engine.
# If set to false, you will be using JSC instead.
diff --git a/example/exampleApp/android/gradle/wrapper/gradle-wrapper.properties b/example/exampleApp/android/gradle/wrapper/gradle-wrapper.properties
index e7646de..79eb9d0 100644
--- a/example/exampleApp/android/gradle/wrapper/gradle-wrapper.properties
+++ b/example/exampleApp/android/gradle/wrapper/gradle-wrapper.properties
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-all.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-all.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
diff --git a/example/exampleApp/android/settings.gradle b/example/exampleApp/android/settings.gradle
index e9ce508..b27a69e 100644
--- a/example/exampleApp/android/settings.gradle
+++ b/example/exampleApp/android/settings.gradle
@@ -1,4 +1,15 @@
-rootProject.name = 'exampleApp'
-apply from: file("../node_modules/@react-native-community/cli-platform-android/native_modules.gradle"); applyNativeModulesSettingsGradle(settings)
-include ':app'
-includeBuild('../node_modules/@react-native/gradle-plugin')
+pluginManagement {
+ includeBuild("../node_modules/@react-native/gradle-plugin")
+}
+
+plugins {
+ id("com.facebook.react.settings")
+}
+
+extensions.configure(com.facebook.react.ReactSettingsExtension) { ex ->
+ ex.autolinkLibrariesFromCommand()
+}
+
+rootProject.name = "exampleApp"
+include(":app")
+includeBuild("../node_modules/@react-native/gradle-plugin")
diff --git a/example/exampleApp/ios/AppDelegate.swift b/example/exampleApp/ios/AppDelegate.swift
index 27c3423..6dc08ee 100644
--- a/example/exampleApp/ios/AppDelegate.swift
+++ b/example/exampleApp/ios/AppDelegate.swift
@@ -1,111 +1,53 @@
import UIKit
import React
+import React_RCTAppDelegate
import UserNotifications
import Mindbox
import MindboxSdk
-
// https://developers.mindbox.ru/docs/ios-send-push-notifications-react-native
-@UIApplicationMain
-class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterDelegate {
-
- var window: UIWindow?
- var bridge: RCTBridge!
-
- func application(_ application: UIApplication,
- didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
+@main
+class AppDelegate: RCTAppDelegate, UNUserNotificationCenterDelegate {
- // Set the current instance of UNUserNotificationCenter's delegate to self.
- // This enables the AppDelegate to respond to notification events
+ override func application(_ application: UIApplication,
+ didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
+ moduleName = "exampleApp"
+ initialProps = [:]
UNUserNotificationCenter.current().delegate = self
-
- // Setting up React Native bridge
- bridge = RCTBridge(delegate: self, launchOptions: launchOptions)
- let rootView = RCTRootView(bridge: bridge, moduleName: "exampleApp", initialProperties: nil)
-
- // Configuring the application window
- self.window = UIWindow(frame: UIScreen.main.bounds)
- let rootViewController = UIViewController()
- rootViewController.view = rootView
- self.window!.rootViewController = rootViewController
- self.window!.makeKeyAndVisible()
-
- // https://developers.mindbox.ru/docs/ios-app-start-tracking-react-native
- // Tracking app launch for analytics
- let trackVisitData = TrackVisitData()
- trackVisitData.launchOptions = launchOptions
- Mindbox.shared.track(data: trackVisitData)
-
- // Register background tasks for iOS 13 and later, or set background fetch interval for earlier versions
- if #available(iOS 13.0, *) {
- Mindbox.shared.registerBGTasks()
- } else {
- UIApplication.shared.setMinimumBackgroundFetchInterval(UIApplication.backgroundFetchIntervalMinimum)
- }
-
- return true
- }
- func notifyReactNative() {
- if let bridge = bridge, let eventEmitter = bridge.module(for: NotificationModule.self) as? NotificationModule {
- eventEmitter.notifyReactNative()
- }
- }
- // Handling remote notification fetch completion
- func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable : Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
- Mindbox.shared.application(application, performFetchWithCompletionHandler: completionHandler)
- notifyReactNative()
+ MindboxApp.configure(launchOptions: launchOptions)
+ return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
- // Updating APNS token in Mindbox
- func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
- Mindbox.shared.apnsTokenUpdate(deviceToken: deviceToken)
+ func notifyReactNativeAboutNotificationCenterUpdate() {
+ NotificationCenter.default.post(name: NotificationCenterStorage.notificationCenterUpdatedName, object: nil)
}
- // Handling Universal Links
- // https://developers.mindbox.ru/docs/ios-app-start-tracking-react-native
- func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool {
- let trackVisitData = TrackVisitData()
- trackVisitData.universalLink = userActivity
- Mindbox.shared.track(data: trackVisitData)
- return true
+ override func application(_ application: UIApplication,
+ didReceiveRemoteNotification userInfo: [AnyHashable : Any],
+ fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
+ notifyReactNativeAboutNotificationCenterUpdate()
+ Mindbox.shared.application(application, performFetchWithCompletionHandler: completionHandler)
}
- // Displaying notifications when the app is active
func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
- notifyReactNative()
+ notifyReactNativeAboutNotificationCenterUpdate()
completionHandler([.alert, .sound, .badge])
}
- // Handling push notification clicks
func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
- // https://developers.mindbox.ru/docs/ios-get-click-react-native
- Mindbox.shared.pushClicked(response: response)
- // https://developers.mindbox.ru/docs/ios-app-start-tracking-react-native
- // Tracking push notification clicks for analytics
- let trackVisitData = TrackVisitData()
- trackVisitData.push = response
- Mindbox.shared.track(data: trackVisitData)
-
- // Emitting event for further handling in JavaScript
- // https://developers.mindbox.ru/docs/flutter-push-navigation-react-native
- MindboxJsDelivery.emitEvent(response)
-
+ notifyReactNativeAboutNotificationCenterUpdate()
completionHandler()
}
-}
-extension AppDelegate: RCTBridgeDelegate {
- func sourceURL(for bridge: RCTBridge!) -> URL! {
+ override func sourceURL(for bridge: RCTBridge!) -> URL! {
+ bundleURL()
+ }
+
+ override func bundleURL() -> URL? {
#if DEBUG
- return RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: "index")
+ RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: "index")
#else
- return Bundle.main.url(forResource: "main", withExtension: "jsbundle")
+ Bundle.main.url(forResource: "main", withExtension: "jsbundle")
#endif
}
-
- func extraModules(for bridge: RCTBridge!) -> [RCTBridgeModule]! {
- var modules = [RCTBridgeModule]()
- modules.append(NotificationModule())
- return modules
- }
}
diff --git a/example/exampleApp/ios/MindboxNotificationServiceExtension/NotificationService.swift b/example/exampleApp/ios/MindboxNotificationServiceExtension/NotificationService.swift
index 50aff5b..7c0dad8 100644
--- a/example/exampleApp/ios/MindboxNotificationServiceExtension/NotificationService.swift
+++ b/example/exampleApp/ios/MindboxNotificationServiceExtension/NotificationService.swift
@@ -4,7 +4,7 @@ import MindboxNotifications
// https://developers.mindbox.ru/docs/ios-send-rich-push-react-native
class NotificationService: UNNotificationServiceExtension {
- static let suiteName = "group.cloud.Mindbox.com.mindbox.exampleRN"
+ static let suiteName = "group.cloud.Mindbox.mindbox.RN.Example"
// Lazy initialization of MindboxNotificationService
lazy var mindboxService = MindboxNotificationService()
diff --git a/example/exampleApp/ios/NotificationCenterStorage.swift b/example/exampleApp/ios/NotificationCenterStorage.swift
new file mode 100644
index 0000000..d1dde96
--- /dev/null
+++ b/example/exampleApp/ios/NotificationCenterStorage.swift
@@ -0,0 +1,28 @@
+import Foundation
+
+@objc(NotificationCenterStorage)
+final class NotificationCenterStorage: NSObject {
+ static let suiteName: String = "group.cloud.Mindbox.mindbox.RN.Example"
+ static let notificationCenterUpdatedRawName: String = "NotificationCenterUpdated"
+ static let notificationCenterUpdatedName: Notification.Name = Notification.Name(notificationCenterUpdatedRawName)
+ private static let notificationsKey: String = "notifications"
+ private static let emptyNotificationsJson: String = "[]"
+
+ @objc static func getNotificationCenterUpdatedName() -> String {
+ return notificationCenterUpdatedRawName
+ }
+
+ @objc static func getNotificationsJson() -> String {
+ guard let userDefaults: UserDefaults = UserDefaults(suiteName: suiteName) else {
+ return emptyNotificationsJson
+ }
+ return userDefaults.string(forKey: notificationsKey) ?? emptyNotificationsJson
+ }
+
+ @objc static func clearNotifications() {
+ let userDefaults: UserDefaults? = UserDefaults(suiteName: suiteName)
+ userDefaults?.set(emptyNotificationsJson, forKey: notificationsKey)
+ userDefaults?.synchronize()
+ }
+
+}
diff --git a/example/exampleApp/ios/NotificationModule.m b/example/exampleApp/ios/NotificationModule.m
deleted file mode 100644
index 6194b78..0000000
--- a/example/exampleApp/ios/NotificationModule.m
+++ /dev/null
@@ -1,7 +0,0 @@
-#import
-#import
-
-@interface RCT_EXTERN_MODULE(NotificationModule, RCTEventEmitter)
-RCT_EXTERN_METHOD(getNotifications:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject)
-RCT_EXTERN_METHOD(clearNotifications:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject)
-@end
diff --git a/example/exampleApp/ios/NotificationModule.mm b/example/exampleApp/ios/NotificationModule.mm
new file mode 100644
index 0000000..dd188a5
--- /dev/null
+++ b/example/exampleApp/ios/NotificationModule.mm
@@ -0,0 +1,64 @@
+#import
+#import
+
+#if __has_include()
+#import
+#elif __has_include("ExampleAppSpec.h")
+#import "ExampleAppSpec.h"
+#else
+#error "ExampleAppSpec.h not found. Ensure React Native codegen is enabled for exampleApp."
+#endif
+
+@interface NotificationCenterStorage : NSObject
++ (NSString *)getNotificationCenterUpdatedName;
++ (NSString *)getNotificationsJson;
++ (void)clearNotifications;
+@end
+
+@interface NotificationModule : NativeNotificationModuleSpecBase
+@end
+
+@implementation NotificationModule
+
+RCT_EXPORT_MODULE(NotificationModule)
+
++ (BOOL)requiresMainQueueSetup {
+ return YES;
+}
+
+- (instancetype)init {
+ self = [super init];
+ if (self) {
+ NSString *eventName = [NotificationCenterStorage getNotificationCenterUpdatedName];
+ [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(handleNotificationCenterUpdated:) name:eventName object:nil];
+ }
+ return self;
+}
+
+- (void)dealloc {
+ [[NSNotificationCenter defaultCenter] removeObserver:self];
+}
+
+- (void)getNotifications:(RCTPromiseResolveBlock)resolve
+ reject:(RCTPromiseRejectBlock)reject {
+ resolve([NotificationCenterStorage getNotificationsJson]);
+}
+
+- (void)clearNotifications:(RCTPromiseResolveBlock)resolve
+ reject:(RCTPromiseRejectBlock)reject {
+ [NotificationCenterStorage clearNotifications];
+ resolve(nil);
+}
+
+- (void)handleNotificationCenterUpdated:(NSNotification *)notification {
+ if (!_eventEmitterCallback) {
+ return;
+ }
+ [self emitOnNotificationCenterUpdated:@{}];
+}
+
+- (std::shared_ptr)getTurboModule:(const facebook::react::ObjCTurboModule::InitParams &)params {
+ return std::make_shared(params);
+}
+
+@end
diff --git a/example/exampleApp/ios/NotificationModule.swift b/example/exampleApp/ios/NotificationModule.swift
deleted file mode 100644
index 421d3af..0000000
--- a/example/exampleApp/ios/NotificationModule.swift
+++ /dev/null
@@ -1,47 +0,0 @@
-import Foundation
-import React
-
-@objc(NotificationModule)
-class NotificationModule: RCTEventEmitter {
-
- static let suiteName = "group.cloud.Mindbox.com.mindbox.exampleRN"
- private var hasListeners = false
-
- override static func requiresMainQueueSetup() -> Bool {
- return true
- }
-
- override func supportedEvents() -> [String]! {
- return ["newNotification"]
- }
-
- // send notification data to RN
- @objc func getNotifications(_ resolve: @escaping RCTPromiseResolveBlock, rejecter reject: @escaping RCTPromiseRejectBlock) {
- let userDefaults = UserDefaults(suiteName: "group.cloud.Mindbox.com.mindbox.exampleRN")
- let notificationsJson = userDefaults?.string(forKey: "notifications") ?? "[]"
- resolve(notificationsJson)
- }
-
- //clear notifications data on native part
- @objc func clearNotifications(_ resolve: @escaping RCTPromiseResolveBlock, rejecter reject: @escaping RCTPromiseRejectBlock) {
- let userDefaults = UserDefaults(suiteName: NotificationModule.suiteName)
- userDefaults?.removeObject(forKey: "notifications")
- userDefaults?.synchronize()
- resolve(nil)
- }
-
- override func startObserving() {
- hasListeners = true
- }
-
- override func stopObserving() {
- hasListeners = false
- }
-
- // send event about new notification
- func notifyReactNative() {
- if hasListeners {
- sendEvent(withName: "newNotification", body: nil)
- }
- }
-}
diff --git a/example/exampleApp/ios/Podfile b/example/exampleApp/ios/Podfile
index b70ff6c..80262f4 100644
--- a/example/exampleApp/ios/Podfile
+++ b/example/exampleApp/ios/Podfile
@@ -1,14 +1,14 @@
-def node_require(script)
- # Resolve script with node to allow for hoisting
- require Pod::Executable.execute_command('node', ['-p',
- "require.resolve(
- '#{script}',
- {paths: [process.argv[1]]},
- )", __dir__]).strip
-end
-
-node_require('react-native/scripts/react_native_pods.rb')
-node_require('react-native-permissions/scripts/setup.rb')
+require Pod::Executable.execute_command('node', ['-p',
+ 'require.resolve(
+ "react-native/scripts/react_native_pods.rb",
+ {paths: [process.argv[1]]},
+ )', __dir__]).strip
+
+require Pod::Executable.execute_command('node', ['-p',
+ 'require.resolve(
+ "react-native-permissions/scripts/setup.rb",
+ {paths: [process.argv[1]]},
+ )', __dir__]).strip
platform :ios, min_ios_version_supported
prepare_react_native_project!
@@ -26,26 +26,25 @@ target 'exampleApp' do
use_react_native!(
:path => config[:reactNativePath],
- # An absolute path to your application root.
:app_path => "#{Pod::Config.instance.installation_root}/.."
)
pod 'Mindbox'
-target 'MindboxNotificationServiceExtension' do
- pod 'MindboxNotifications'
+ target 'MindboxNotificationServiceExtension' do
+ pod 'MindboxNotifications'
end
-target 'MindboxNotificationContentExtension' do
- pod 'MindboxNotifications'
+ target 'MindboxNotificationContentExtension' do
+ pod 'MindboxNotifications'
end
post_install do |installer|
installer.pods_project.targets.each do |target|
- target.build_configurations.each do |config|
- config.build_settings['APPLICATION_EXTENSION_API_ONLY'] = 'No'
- end
- end
+ target.build_configurations.each do |buildConfig|
+ buildConfig.build_settings['APPLICATION_EXTENSION_API_ONLY'] = 'No'
+ end
+ end
react_native_post_install(
installer,
config[:reactNativePath],
@@ -53,4 +52,3 @@ target 'MindboxNotificationContentExtension' do
)
end
end
-
diff --git a/example/exampleApp/ios/Swift.swift b/example/exampleApp/ios/Swift.swift
deleted file mode 100644
index 350c82f..0000000
--- a/example/exampleApp/ios/Swift.swift
+++ /dev/null
@@ -1,2 +0,0 @@
-
-import Foundation
diff --git a/example/exampleApp/ios/exampleApp.xcodeproj/project.pbxproj b/example/exampleApp/ios/exampleApp.xcodeproj/project.pbxproj
index a267e63..273adf1 100644
--- a/example/exampleApp/ios/exampleApp.xcodeproj/project.pbxproj
+++ b/example/exampleApp/ios/exampleApp.xcodeproj/project.pbxproj
@@ -9,11 +9,10 @@
/* Begin PBXBuildFile section */
02ADDBF8A19984F9D26FCB4E /* libPods-exampleApp-MindboxNotificationServiceExtension.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 9D15FE27D8E0EFFC720594B8 /* libPods-exampleApp-MindboxNotificationServiceExtension.a */; };
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; };
- 3A027BAC2C3BF24F005415BB /* NotificationModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A027BAB2C3BF24F005415BB /* NotificationModule.swift */; };
- 3A027BAE2C3BFC4D005415BB /* NotificationModule.m in Sources */ = {isa = PBXBuildFile; fileRef = 3A027BAD2C3BFC4D005415BB /* NotificationModule.m */; };
+ 3A027BAC2C3BF24F005415BB /* NotificationCenterStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A027BAB2C3BF24F005415BB /* NotificationCenterStorage.swift */; };
+ 3A027BAE2C3BFC4D005415BB /* NotificationModule.mm in Sources */ = {isa = PBXBuildFile; fileRef = 3A027BAD2C3BFC4D005415BB /* NotificationModule.mm */; };
3A175C362C3D1B800027776A /* PushAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A175C322C3D16C70027776A /* PushAction.swift */; };
3A175C372C3D1B800027776A /* MindboxRemoteMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A175C342C3D16F50027776A /* MindboxRemoteMessage.swift */; };
- 3A88FC072B6BDD900046E687 /* Swift.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A88FC062B6BDD900046E687 /* Swift.swift */; };
3A88FC092B6BDF360046E687 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A88FC082B6BDF360046E687 /* AppDelegate.swift */; };
3ACCD5142B72826700C94F45 /* MindboxNotificationServiceExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 3ACCD50D2B72826700C94F45 /* MindboxNotificationServiceExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
3ACCD51C2B728BD500C94F45 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3ACCD51B2B728BD500C94F45 /* NotificationService.swift */; };
@@ -65,11 +64,10 @@
13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = exampleApp/Info.plist; sourceTree = ""; };
22407D2A15E8083A8FEC02A6 /* Pods-exampleApp-MindboxNotificationServiceExtension.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-exampleApp-MindboxNotificationServiceExtension.release.xcconfig"; path = "Target Support Files/Pods-exampleApp-MindboxNotificationServiceExtension/Pods-exampleApp-MindboxNotificationServiceExtension.release.xcconfig"; sourceTree = ""; };
225AB1FB2853D87400D56406 /* libPods-exampleApp.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-exampleApp.a"; sourceTree = BUILT_PRODUCTS_DIR; };
- 3A027BAB2C3BF24F005415BB /* NotificationModule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationModule.swift; sourceTree = ""; };
- 3A027BAD2C3BFC4D005415BB /* NotificationModule.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = NotificationModule.m; sourceTree = ""; };
+ 3A027BAB2C3BF24F005415BB /* NotificationCenterStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationCenterStorage.swift; sourceTree = ""; };
+ 3A027BAD2C3BFC4D005415BB /* NotificationModule.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = NotificationModule.mm; sourceTree = ""; };
3A175C322C3D16C70027776A /* PushAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushAction.swift; sourceTree = ""; };
3A175C342C3D16F50027776A /* MindboxRemoteMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MindboxRemoteMessage.swift; sourceTree = ""; };
- 3A88FC062B6BDD900046E687 /* Swift.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Swift.swift; sourceTree = ""; };
3A88FC082B6BDF360046E687 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; };
3A88FC0A2B6BF2840046E687 /* exampleApp-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "exampleApp-Bridging-Header.h"; sourceTree = ""; };
3A88FC0B2B6CEE180046E687 /* exampleApp.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; name = exampleApp.entitlements; path = exampleApp/exampleApp.entitlements; sourceTree = ""; };
@@ -197,10 +195,9 @@
isa = PBXGroup;
children = (
3A175C312C3D16B20027776A /* Models */,
- 3A027BAD2C3BFC4D005415BB /* NotificationModule.m */,
- 3A027BAB2C3BF24F005415BB /* NotificationModule.swift */,
+ 3A027BAD2C3BFC4D005415BB /* NotificationModule.mm */,
+ 3A027BAB2C3BF24F005415BB /* NotificationCenterStorage.swift */,
3A88FC0A2B6BF2840046E687 /* exampleApp-Bridging-Header.h */,
- 3A88FC062B6BDD900046E687 /* Swift.swift */,
13B07FAE1A68108700A75B9A /* exampleApp */,
832341AE1AAA6A7D00B99B32 /* Libraries */,
3ACCD50E2B72826700C94F45 /* MindboxNotificationServiceExtension */,
@@ -530,10 +527,9 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
- 3A027BAE2C3BFC4D005415BB /* NotificationModule.m in Sources */,
+ 3A027BAE2C3BFC4D005415BB /* NotificationModule.mm in Sources */,
3A88FC092B6BDF360046E687 /* AppDelegate.swift in Sources */,
- 3A88FC072B6BDD900046E687 /* Swift.swift in Sources */,
- 3A027BAC2C3BF24F005415BB /* NotificationModule.swift in Sources */,
+ 3A027BAC2C3BF24F005415BB /* NotificationCenterStorage.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -585,6 +581,7 @@
DEVELOPMENT_TEAM = 622436AMYX;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = exampleApp/Info.plist;
+ IPHONEOS_DEPLOYMENT_TARGET = 15.6;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@@ -619,6 +616,7 @@
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 622436AMYX;
INFOPLIST_FILE = exampleApp/Info.plist;
+ IPHONEOS_DEPLOYMENT_TARGET = 15.6;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@@ -663,7 +661,7 @@
INFOPLIST_FILE = MindboxNotificationServiceExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = MindboxNotificationServiceExtension;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
- IPHONEOS_DEPLOYMENT_TARGET = 13.4;
+ IPHONEOS_DEPLOYMENT_TARGET = 15.6;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@@ -710,7 +708,7 @@
INFOPLIST_FILE = MindboxNotificationServiceExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = MindboxNotificationServiceExtension;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
- IPHONEOS_DEPLOYMENT_TARGET = 13.4;
+ IPHONEOS_DEPLOYMENT_TARGET = 15.6;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@@ -754,7 +752,7 @@
INFOPLIST_FILE = MindboxNotificationContentExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = MindboxNotificationContentExtension;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
- IPHONEOS_DEPLOYMENT_TARGET = 13.4;
+ IPHONEOS_DEPLOYMENT_TARGET = 15.6;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@@ -800,7 +798,7 @@
INFOPLIST_FILE = MindboxNotificationContentExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = MindboxNotificationContentExtension;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
- IPHONEOS_DEPLOYMENT_TARGET = 13.4;
+ IPHONEOS_DEPLOYMENT_TARGET = 15.6;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@@ -898,7 +896,7 @@
);
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
SDKROOT = iphoneos;
- SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
+ SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG";
USE_HERMES = true;
};
name = Debug;
diff --git a/example/exampleApp/ios/exampleApp/PrivacyInfo.xcprivacy b/example/exampleApp/ios/exampleApp/PrivacyInfo.xcprivacy
index 01ce452..2d04442 100644
--- a/example/exampleApp/ios/exampleApp/PrivacyInfo.xcprivacy
+++ b/example/exampleApp/ios/exampleApp/PrivacyInfo.xcprivacy
@@ -2,10 +2,33 @@
- NSPrivacyTracking
-
- NSPrivacyTrackingDomains
-
+ NSPrivacyAccessedAPITypes
+
+
+ NSPrivacyAccessedAPIType
+ NSPrivacyAccessedAPICategoryUserDefaults
+ NSPrivacyAccessedAPITypeReasons
+
+ CA92.1
+
+
+
+ NSPrivacyAccessedAPIType
+ NSPrivacyAccessedAPICategoryFileTimestamp
+ NSPrivacyAccessedAPITypeReasons
+
+ C617.1
+
+
+
+ NSPrivacyAccessedAPIType
+ NSPrivacyAccessedAPICategorySystemBootTime
+ NSPrivacyAccessedAPITypeReasons
+
+ 35F9.1
+
+
+
NSPrivacyCollectedDataTypes
@@ -13,36 +36,29 @@
NSPrivacyCollectedDataTypeOtherDiagnosticData
NSPrivacyCollectedDataTypeLinked
- NSPrivacyCollectedDataTypeTracking
-
NSPrivacyCollectedDataTypePurposes
NSPrivacyCollectedDataTypePurposeAppFunctionality
+ NSPrivacyCollectedDataTypeTracking
+
NSPrivacyCollectedDataType
NSPrivacyCollectedDataTypeDeviceID
NSPrivacyCollectedDataTypeLinked
- NSPrivacyCollectedDataTypeTracking
-
NSPrivacyCollectedDataTypePurposes
NSPrivacyCollectedDataTypePurposeProductPersonalization
+ NSPrivacyCollectedDataTypeTracking
+
- NSPrivacyAccessedAPITypes
-
-
- NSPrivacyAccessedAPIType
- NSPrivacyAccessedAPICategoryUserDefaults
- NSPrivacyAccessedAPITypeReasons
-
- CA92.1
-
-
-
+ NSPrivacyTracking
+
+ NSPrivacyTrackingDomains
+
diff --git a/example/exampleApp/package.json b/example/exampleApp/package.json
index a755306..d6f9b2c 100644
--- a/example/exampleApp/package.json
+++ b/example/exampleApp/package.json
@@ -12,26 +12,23 @@
"test": "jest"
},
"dependencies": {
- "@react-navigation/native": "^6.1.6",
- "@react-navigation/native-stack": "^6.9.17",
- "@react-navigation/stack": "^6.3.20",
- "mindbox-sdk": "^2.13.1",
- "react": "18.2.0",
- "react-native": "0.74.0",
- "react-native-gesture-handler": "2.21.2",
+ "mindbox-sdk": "^3.0.0-rc",
+ "react": "18.3.1",
+ "react-native": "0.76.0",
"react-native-permissions": "^5.4.0",
- "react-native-safe-area-context": "^4.9.0",
- "react-native-screens": "^3.29.0",
"react-native-snackbar": "^2.8.0"
},
"devDependencies": {
- "@babel/core": "^7.20.0",
- "@babel/preset-env": "^7.20.0",
- "@babel/runtime": "^7.20.0",
- "@react-native/babel-preset": "0.74.0",
- "@react-native/eslint-config": "0.74.0",
- "@react-native/metro-config": "0.74.0",
- "@react-native/typescript-config": "0.74.0",
+ "@babel/core": "^7.25.2",
+ "@babel/preset-env": "^7.25.3",
+ "@babel/runtime": "^7.25.0",
+ "@react-native-community/cli": "15.0.0",
+ "@react-native-community/cli-platform-android": "15.0.0",
+ "@react-native-community/cli-platform-ios": "15.0.0",
+ "@react-native/babel-preset": "0.76.0",
+ "@react-native/eslint-config": "0.76.0",
+ "@react-native/metro-config": "0.76.0",
+ "@react-native/typescript-config": "0.76.0",
"@types/react": "^18.2.6",
"@types/react-test-renderer": "^18.0.0",
"babel-jest": "^29.6.3",
@@ -39,10 +36,18 @@
"husky": "^9.0.10",
"jest": "^29.6.3",
"prettier": "2.8.8",
- "react-test-renderer": "18.2.0",
+ "react-test-renderer": "18.3.1",
"typescript": "5.0.4"
},
"engines": {
"node": ">=18"
+ },
+ "codegenConfig": {
+ "name": "ExampleAppSpec",
+ "type": "modules",
+ "jsSrcsDir": "src",
+ "android": {
+ "javaPackageName": "com.exampleapp"
+ }
}
}
diff --git a/example/exampleApp/src/App.tsx b/example/exampleApp/src/App.tsx
index 5330f4d..3f4caf0 100644
--- a/example/exampleApp/src/App.tsx
+++ b/example/exampleApp/src/App.tsx
@@ -1,21 +1,28 @@
-import React from 'react'
-import { NavigationContainer } from '@react-navigation/native'
-import { createNativeStackNavigator } from '@react-navigation/native-stack'
+import React, { useMemo, useState } from 'react'
+import { AppNavigationContext, RouteName } from './navigation/AppNavigationContext'
import HomeScreen from './screens/HomeScreen'
import PushNotificationScreen from './screens/PushNotificationScreen'
import NotificationCenterScreen from './screens/NotificationCenterScreen'
-const Stack = createNativeStackNavigator()
-
function App() {
+ const [route, setRoute] = useState('Home')
+ const navigation = useMemo(
+ () => ({
+ navigate: (name: RouteName) => {
+ setRoute(name)
+ },
+ goBack: () => {
+ setRoute('Home')
+ },
+ }),
+ []
+ )
return (
-
-
-
-
-
-
-
+
+ {route === 'Home' ? : null}
+ {route === 'PushNotification' ? : null}
+ {route === 'NotificationCenter' ? : null}
+
)
}
diff --git a/example/exampleApp/src/native/NativeNotificationModule.ts b/example/exampleApp/src/native/NativeNotificationModule.ts
new file mode 100644
index 0000000..455f3b2
--- /dev/null
+++ b/example/exampleApp/src/native/NativeNotificationModule.ts
@@ -0,0 +1,13 @@
+import type { TurboModule } from 'react-native'
+import { TurboModuleRegistry } from 'react-native'
+import type { EventEmitter } from 'react-native/Libraries/Types/CodegenTypes'
+
+export type NotificationCenterUpdatedEvent = {}
+
+export interface Spec extends TurboModule {
+ getNotifications(): Promise
+ clearNotifications(): Promise
+ readonly onNotificationCenterUpdated: EventEmitter
+}
+
+export default TurboModuleRegistry.getEnforcing('NotificationModule')
diff --git a/example/exampleApp/src/navigation/AppNavigationContext.tsx b/example/exampleApp/src/navigation/AppNavigationContext.tsx
new file mode 100644
index 0000000..f73ba1b
--- /dev/null
+++ b/example/exampleApp/src/navigation/AppNavigationContext.tsx
@@ -0,0 +1,20 @@
+import { createContext, useContext } from 'react'
+
+export type RouteName = 'Home' | 'PushNotification' | 'NotificationCenter'
+
+export type AppNavigation = {
+ navigate(name: RouteName): void
+ goBack(): void
+}
+
+const AppNavigationContext = createContext(null)
+
+export function useAppNavigation(): AppNavigation {
+ const value = useContext(AppNavigationContext)
+ if (value == null) {
+ throw new Error('useAppNavigation must be used within AppNavigationContext.Provider')
+ }
+ return value
+}
+
+export { AppNavigationContext }
diff --git a/example/exampleApp/src/screens/HomeScreen.tsx b/example/exampleApp/src/screens/HomeScreen.tsx
index 17c52e4..ebb9573 100644
--- a/example/exampleApp/src/screens/HomeScreen.tsx
+++ b/example/exampleApp/src/screens/HomeScreen.tsx
@@ -3,8 +3,7 @@ import { SafeAreaView, StyleSheet, Text, View, Platform, Button } from 'react-na
import MindboxSdk, { LogLevel, CopyPayloadInAppCallback, EmptyInAppCallback, InAppCallback, UrlInAppCallback } from 'mindbox-sdk'
import { sendSync, sendAsync, asyncOperationNCOpen } from '../utils/MindboxOperations'
import { requestNotificationPermission } from '../utils/RequestPermission'
-import PushNotificationScreen from './screens/PushNotificationScreen'
-import { useNavigation } from '@react-navigation/native'
+import { useAppNavigation } from '../navigation/AppNavigationContext'
import { chooseInappCallback, RegisterInappCallback } from '../utils/InAppCallbacks'
const configuration = {
@@ -16,7 +15,7 @@ const configuration = {
}
const HomeScreen = () => {
- const navigation = useNavigation()
+ const navigation = useAppNavigation()
const [deviceUUID, setDeviceUUID] = useState('Empty')
const [token, setToken] = useState('Empty')
const [pushData, setPushData] = useState({
@@ -25,6 +24,35 @@ const HomeScreen = () => {
})
const [sdkVersion, setSdkVersion] = useState('Empty')
+ const appInitializationCallback = useCallback(async () => {
+ try {
+ // https://developers.mindbox.ru/docs/%D0%BC%D0%B5%D1%82%D0%BE%D0%B4%D1%8B-react-natice-sdk#mindboxinitialize
+ await MindboxSdk.initialize(configuration)
+ } catch (error) {
+ console.log(error)
+ }
+ }, [])
+
+ const navigateToPushNotificationIfRequired = useCallback(
+ (pushUrl: string | null) => {
+ if (pushUrl != null && pushUrl.includes('gotoanotherscreen')) {
+ navigation.navigate('PushNotification')
+ }
+ },
+ [navigation]
+ )
+
+ const getPushData = useCallback(
+ (pushUrl: string | null, pushPayload: string | null) => {
+ setTimeout(() => {
+ // https://developers.mindbox.ru/docs/flutter-push-navigation-react-native
+ navigateToPushNotificationIfRequired(pushUrl)
+ setPushData({ pushUrl, pushPayload })
+ }, 600)
+ },
+ [navigateToPushNotificationIfRequired]
+ )
+
useEffect(() => {
// https://developers.mindbox.ru/docs/%D0%BC%D0%B5%D1%82%D0%BE%D0%B4%D1%8B-react-natice-sdk#setloglevel-since-280
MindboxSdk.setLogLevel(LogLevel.DEBUG)
@@ -51,26 +79,6 @@ const HomeScreen = () => {
chooseInappCallback(RegisterInappCallback.DEFAULT)
}, [appInitializationCallback])
- const appInitializationCallback = useCallback(async () => {
- try {
- // https://developers.mindbox.ru/docs/%D0%BC%D0%B5%D1%82%D0%BE%D0%B4%D1%8B-react-natice-sdk#mindboxinitialize
- await MindboxSdk.initialize(configuration)
- } catch (error) {
- console.log(error)
- }
- }, [])
-
- const getPushData = useCallback(
- (pushUrl: String | null, pushPayload: String | null) => {
- setTimeout(() => {
- // https://developers.mindbox.ru/docs/flutter-push-navigation-react-native
- navigateToPushNotificationIfRequired(pushUrl)
- setPushData({ pushUrl, pushPayload })
- }, 600)
- },
- [navigateToPushNotificationIfRequired]
- )
-
useEffect(() => {
// https://developers.mindbox.ru/docs/%D0%BC%D0%B5%D1%82%D0%BE%D0%B4%D1%8B-react-natice-sdk#onpushclickreceived
MindboxSdk.onPushClickReceived(getPushData)
@@ -88,15 +96,6 @@ const HomeScreen = () => {
asyncOperationNCOpen()
navigation.navigate('NotificationCenter')
}
-
- const navigateToPushNotificationIfRequired = useCallback(
- (pushUrl) => {
- if (pushUrl && pushUrl.includes('gotoanotherscreen')) {
- navigation.navigate('PushNotification')
- }
- },
- [navigation]
- )
return (
diff --git a/example/exampleApp/src/screens/NotificationCenterScreen.tsx b/example/exampleApp/src/screens/NotificationCenterScreen.tsx
index 4abc0ab..decb715 100644
--- a/example/exampleApp/src/screens/NotificationCenterScreen.tsx
+++ b/example/exampleApp/src/screens/NotificationCenterScreen.tsx
@@ -1,25 +1,22 @@
-import { NativeModules, NativeEventEmitter } from 'react-native'
import React, { useEffect, useState } from 'react'
-import { View, Text, Button, FlatList, StyleSheet } from 'react-native'
+import { View, Button, FlatList } from 'react-native'
import NotificationItem from '../components/NotificationItem'
import { asyncOperationNCPushOpen } from '../utils/MindboxOperations'
import initialNotifications from '../utils/NotificationStub'
import styles from '../components/NotificationScreenStyles'
import { Notification } from '../utils/Notification'
+import { useAppNavigation } from '../navigation/AppNavigationContext'
+import NotificationModule from '../native/NativeNotificationModule'
-const { NotificationModule } = NativeModules
-const notificationEmitter = new NativeEventEmitter(NotificationModule)
-
-const NotificationCenterScreen = ({ navigation }: { navigation: any }) => {
+const NotificationCenterScreen = () => {
+ const navigation = useAppNavigation()
const [notifications, setNotifications] = useState([])
useEffect(() => {
loadNotifications()
-
- const subscription = notificationEmitter.addListener('newNotification', () => {
+ const subscription = NotificationModule.onNotificationCenterUpdated(() => {
loadNotifications()
})
-
return () => {
subscription.remove()
}
@@ -28,7 +25,7 @@ const NotificationCenterScreen = ({ navigation }: { navigation: any }) => {
const loadNotifications = async () => {
try {
const result = await NotificationModule.getNotifications()
- const notificationStrings = JSON.parse(result)
+ const notificationStrings: string[] = JSON.parse(result)
const notificationList = notificationStrings.map((notificationString: string) => {
const notification = JSON.parse(notificationString)
/*
@@ -38,9 +35,9 @@ const NotificationCenterScreen = ({ navigation }: { navigation: any }) => {
}
*/
if (notification.payload) {
- const payload = JSON.parse(notification.payload)
- notification.pushName = payload.pushName
- notification.pushDate = payload.pushDate
+ const payload: { pushName?: string; pushDate?: string } = JSON.parse(notification.payload)
+ notification.pushName = payload.pushName ?? ''
+ notification.pushDate = payload.pushDate ?? ''
} else {
notification.pushName = ''
notification.pushDate = ''
diff --git a/example/exampleApp/src/screens/PushNotificationScreen.tsx b/example/exampleApp/src/screens/PushNotificationScreen.tsx
index 13297a0..9b94a66 100644
--- a/example/exampleApp/src/screens/PushNotificationScreen.tsx
+++ b/example/exampleApp/src/screens/PushNotificationScreen.tsx
@@ -1,10 +1,14 @@
import React from 'react'
-import { View, Text, StyleSheet } from 'react-native'
+import { View, Text, StyleSheet, Button } from 'react-native'
+import { useAppNavigation } from '../navigation/AppNavigationContext'
const PushNotificationScreen = () => {
+ const navigation = useAppNavigation()
return (
Opened after click on push
+
+
)
}
@@ -19,6 +23,9 @@ const styles = StyleSheet.create({
fontSize: 20,
textAlign: 'center',
},
+ spacing: {
+ height: 24,
+ },
})
export default PushNotificationScreen
diff --git a/ios/EmbeddedBlock/MindboxEmbeddedBlockHost.swift b/ios/EmbeddedBlock/MindboxEmbeddedBlockHost.swift
new file mode 100644
index 0000000..b19f90a
--- /dev/null
+++ b/ios/EmbeddedBlock/MindboxEmbeddedBlockHost.swift
@@ -0,0 +1,130 @@
+import UIKit
+@_spi(Internal) import Mindbox
+import MindboxLogger
+
+/// The Swift side of one embedded block: the SDK's own container, plus the two signals it sends up.
+///
+/// The block itself is `MindboxEmbeddedBlockView`, whole and unchanged — the resolver, the waiting
+/// budget, the page and its bridge stay on the native side, and React Native gets a view to place and
+/// the signals to react to.
+///
+/// Why a separate class at all: the Fabric component view is Objective-C++ (its base class and the
+/// generated event emitters are C++), and the block's wrapper API is behind `@_spi(Internal)`, which
+/// only Swift can import. So the block lives here and the component view drives it — the same split
+/// the module already uses for its Turbo module.
+@objc(MindboxEmbeddedBlockHost)
+public final class MindboxEmbeddedBlockHost: NSObject {
+
+ /// Native → RN: where the block stands now, as one of the wire words.
+ @objc public var onAppearance: ((NSString) -> Void)?
+
+ /// Native → RN: how the load ended — `load` or `fail`.
+ @objc public var onOutcome: ((NSString) -> Void)?
+
+ /// The view to put on screen.
+ @objc public var view: UIView { blockView }
+
+ private let blockView: MindboxEmbeddedBlockView
+ private var isTornDown = false
+
+ /// `timeoutMs` comes in the wire spelling — whole milliseconds, zero for "the host said nothing".
+ /// Zero turns back into the nil the SDK reads as its own default; anything else — a negative
+ /// included — is converted to the seconds the container counts in and handed over as it is, for the
+ /// container to sanitize and log.
+ @objc public init(placeSystemName: String, height: CGFloat, timeoutMs: Double) {
+ let timeout: TimeInterval? = timeoutMs == 0 ? nil : timeoutMs / 1000
+ blockView = MindboxEmbeddedBlockView(placeSystemName: placeSystemName, height: height, timeout: timeout)
+ super.init()
+
+ if placeSystemName.isEmpty {
+ Logger.common(message: "[EmbeddedBlock] A React Native block was created without a place system name and has nothing to resolve",
+ level: .error,
+ category: .embeddedBlocks)
+ }
+
+ blockView.delegate = self
+ // Last: subscribing hands out the current appearance right away, and a place with nothing behind
+ // it settles synchronously inside this call.
+ blockView.setAppearanceObserver { [weak self] appearance in
+ self?.onAppearance?(Self.name(of: appearance) as NSString)
+ }
+ }
+
+ /// Whether the host still shows the block. `true` by default, so a caller that says nothing behaves
+ /// as a native host does.
+ @objc public func setHostVisible(_ isHostVisible: Bool) {
+ blockView.setHostVisible(isHostVisible)
+ }
+
+ /// Puts an empty view where the host draws its own screen.
+ ///
+ /// An RN node cannot be handed to the container: it belongs to Fabric, which mounts it, and to Yoga,
+ /// which lays it out. So the container is not given the screen — it is given the fact that the place
+ /// is taken. That is all it needs: its own shimmer is held back, and a failed block keeps its height
+ /// instead of collapsing. What is drawn there is an RN overlay above this view.
+ @objc public func setStandIns(hasPlaceholder: Bool, hasErrorView: Bool) {
+ if hasPlaceholder {
+ if blockView.placeholderView == nil {
+ blockView.placeholderView = Self.makeStandIn()
+ }
+ } else {
+ blockView.placeholderView = nil
+ }
+
+ if hasErrorView {
+ if blockView.errorView == nil {
+ blockView.errorView = Self.makeStandIn()
+ }
+ } else {
+ blockView.errorView = nil
+ }
+ }
+
+ /// The RN view is gone, so the block's screen is gone with it.
+ ///
+ /// Not named `release`: Objective-C does not allow a method by that name, and this class is driven
+ /// from Objective-C++.
+ @objc public func tearDown() {
+ guard !isTornDown else { return }
+
+ isTornDown = true
+ onAppearance = nil
+ onOutcome = nil
+ blockView.setAppearanceObserver(nil)
+ blockView.delegate = nil
+ blockView.release()
+ }
+
+ private static func makeStandIn() -> UIView {
+ let standIn = UIView()
+ standIn.backgroundColor = .clear
+ // The stand-in is a placeholder for space, not for touches: what the host drew over it is an RN
+ // view, and it is RN that has to hear the taps on it.
+ standIn.isUserInteractionEnabled = false
+ return standIn
+ }
+
+ /// Spelled out rather than derived from the case name: the wire word is a contract with the JS side,
+ /// and renaming a case in the SDK must not quietly change it.
+ private static func name(of appearance: MindboxEmbeddedBlockAppearance) -> String {
+ switch appearance {
+ case .placeholder: return "placeholder"
+ case .content: return "content"
+ case .error: return "error"
+ case .collapsed: return "collapsed"
+ }
+ }
+}
+
+// MARK: - MindboxEmbeddedBlockViewDelegate
+
+extension MindboxEmbeddedBlockHost: MindboxEmbeddedBlockViewDelegate {
+
+ public func mindboxEmbeddedBlockViewDidLoad(_ blockView: MindboxEmbeddedBlockView) {
+ onOutcome?("load")
+ }
+
+ public func mindboxEmbeddedBlockViewDidFail(_ blockView: MindboxEmbeddedBlockView) {
+ onOutcome?("fail")
+ }
+}
diff --git a/ios/EmbeddedBlock/MindboxEmbeddedBlockView.mm b/ios/EmbeddedBlock/MindboxEmbeddedBlockView.mm
new file mode 100644
index 0000000..4d72277
--- /dev/null
+++ b/ios/EmbeddedBlock/MindboxEmbeddedBlockView.mm
@@ -0,0 +1,231 @@
+#import
+
+#import
+#import
+#import
+#import
+
+#if __has_include("MindboxSdk-Swift.h")
+#import "MindboxSdk-Swift.h"
+#elif __has_include()
+#import
+#else
+#error "MindboxSdk-Swift.h not found. Ensure Swift sources are included in the MindboxSdk pod target."
+#endif
+
+using namespace facebook::react;
+
+/**
+ * The embedded block as a Fabric component.
+ *
+ * The block itself lives in `MindboxEmbeddedBlockHost` — Swift, because the wrapper API of the native
+ * SDK is behind `@_spi(Internal)`. This view is the part that has to be Objective-C++: the base class
+ * and the generated event emitters are C++.
+ */
+@interface MindboxEmbeddedBlockViewComponentView : RCTViewComponentView
+@end
+
+@implementation MindboxEmbeddedBlockViewComponentView {
+ MindboxEmbeddedBlockHost *_host;
+
+ std::string _placeSystemName;
+ CGFloat _blockHeight;
+ double _timeoutMs;
+ BOOL _hasPlaceholder;
+ BOOL _hasErrorView;
+ BOOL _isHostVisible;
+
+ /// What the block said before there was an event emitter to say it to. See `updateEventEmitter:`.
+ NSString *_pendingAppearance;
+ NSString *_pendingOutcome;
+}
+
++ (ComponentDescriptorProvider)componentDescriptorProvider
+{
+ return concreteComponentDescriptorProvider();
+}
+
+/**
+ * Never recycled.
+ *
+ * Fabric would hand this view to another place, and the block inside it cannot be revived — `tearDown`
+ * is one way. Creating the block with the view and killing it with the view is what keeps the lifecycle
+ * here simple, and a pooled empty container buys nothing.
+ */
++ (BOOL)shouldBeRecycled
+{
+ return NO;
+}
+
+- (instancetype)initWithFrame:(CGRect)frame
+{
+ if (self = [super initWithFrame:frame]) {
+ static const auto defaultProps = std::make_shared();
+ _props = defaultProps;
+ _isHostVisible = YES;
+ }
+
+ return self;
+}
+
+- (void)dealloc
+{
+ [_host tearDown];
+}
+
+- (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared &)oldProps
+{
+ const auto &next = *std::static_pointer_cast(props);
+
+ if (next.placeSystemName != _placeSystemName) {
+ // A different place is a different block, and the old one has nothing to hand over. The JS
+ // wrapper keys the whole component by the place, so this is a safety net rather than the usual
+ // path — but a place changed under a live block must not leave the old one running.
+ _placeSystemName = next.placeSystemName;
+ [self dropHost];
+ }
+
+ _blockHeight = next.blockHeight;
+ // Read on every update, used once: the block takes its budget when it is built, in
+ // `finalizeUpdates:`, and a running wait cannot be re-budgeted. The JS wrapper freezes the value
+ // and warns about a change; a change that reaches this far simply lands after the one read.
+ _timeoutMs = next.timeoutMs;
+ _hasPlaceholder = next.hasPlaceholder;
+ _hasErrorView = next.hasErrorView;
+ _isHostVisible = next.hostVisible;
+
+ if (_host != nil) {
+ [_host setStandInsWithHasPlaceholder:_hasPlaceholder hasErrorView:_hasErrorView];
+ [_host setHostVisible:_isHostVisible];
+ }
+
+ [super updateProps:props oldProps:oldProps];
+}
+
+/**
+ * The block is built here and not in `updateProps:`.
+ *
+ * Mounting applies the props first and the event emitter after them, and the container hands out its
+ * appearance the moment the observer subscribes — a place with nothing behind it settles right there. A
+ * block built while applying props would report its whole life to nobody. By `finalizeUpdates:` the
+ * emitter and the layout metrics are both in.
+ *
+ * An empty place system name is a name like any other here: the container resolves a nameless place as
+ * an empty one and answers `collapsed` and a failure, which is the host's cue to give the space back.
+ * Refusing to build the block instead would leave the place taken for the life of the screen, with
+ * nothing ever reported — the one outcome a host cannot lay out around.
+ */
+- (void)finalizeUpdates:(RNComponentViewUpdateMask)updateMask
+{
+ [super finalizeUpdates:updateMask];
+
+ // A frame of no size is no time to start: a page laid out against a zero viewport does not lay
+ // itself out again when the space arrives, and the block would report content nobody can see. This
+ // is not a refusal for good — a change of layout metrics is a reason for another `finalizeUpdates:`
+ // on its own, and the metrics land before it, so the first frame with a size builds the block. The
+ // same guard Android keeps in `MindboxEmbeddedBlockHostView.buildBlockIfPossible`.
+ if (_host != nil || CGRectIsEmpty(self.bounds)) {
+ return;
+ }
+
+ _host = [[MindboxEmbeddedBlockHost alloc] initWithPlaceSystemName:@(_placeSystemName.c_str())
+ height:_blockHeight
+ timeoutMs:_timeoutMs];
+ [_host setStandInsWithHasPlaceholder:_hasPlaceholder hasErrorView:_hasErrorView];
+ [_host setHostVisible:_isHostVisible];
+
+ __weak MindboxEmbeddedBlockViewComponentView *weakSelf = self;
+ _host.onAppearance = ^(NSString *appearance) {
+ [weakSelf emitAppearance:appearance];
+ };
+ _host.onOutcome = ^(NSString *outcome) {
+ [weakSelf emitOutcome:outcome];
+ };
+
+ // `contentView` and not `addSubview:`: this is the one subview Fabric keeps sized to the view's
+ // content frame, so the block follows whatever height the style gives it. The frame is the host's
+ // business, not the block's — the container sizes itself by `intrinsicContentSize` for a native
+ // host, and here RN owns the layout.
+ self.contentView = _host.view;
+}
+
+- (void)updateEventEmitter:(const EventEmitter::Shared &)eventEmitter
+{
+ [super updateEventEmitter:eventEmitter];
+
+ // Whatever the block reported before this point had nowhere to go. It reports states and not
+ // deltas, so replaying the last one says everything.
+ if (_pendingAppearance != nil) {
+ NSString *appearance = _pendingAppearance;
+ _pendingAppearance = nil;
+ [self emitAppearance:appearance];
+ }
+
+ if (_pendingOutcome != nil) {
+ NSString *outcome = _pendingOutcome;
+ _pendingOutcome = nil;
+ [self emitOutcome:outcome];
+ }
+}
+
+/**
+ * The view has been unmounted, and it is not going to a recycle pool — `shouldBeRecycled` says so, and
+ * that is exactly why React Native calls this and not `prepareForRecycle`.
+ *
+ * The block's screen ends here, not whenever the last reference to the view is let go: waiting for
+ * `dealloc` would leave the page alive for an autorelease pool to decide about. Android tears the block
+ * down at the same moment, in `onDropViewInstance`.
+ */
+- (void)invalidate
+{
+ [self dropHost];
+ _placeSystemName = "";
+}
+
+- (void)emitAppearance:(NSString *)appearance
+{
+ if (!_eventEmitter) {
+ _pendingAppearance = appearance;
+ return;
+ }
+
+ std::static_pointer_cast(_eventEmitter)
+ ->onAppearanceChange({.appearance = std::string([appearance UTF8String])});
+}
+
+- (void)emitOutcome:(NSString *)outcome
+{
+ if (!_eventEmitter) {
+ _pendingOutcome = outcome;
+ return;
+ }
+
+ const auto emitter = std::static_pointer_cast(_eventEmitter);
+ if ([outcome isEqualToString:@"load"]) {
+ emitter->onBlockLoad({});
+ } else {
+ emitter->onBlockFail({});
+ }
+}
+
+- (void)dropHost
+{
+ if (_host == nil) {
+ return;
+ }
+
+ [_host tearDown];
+ self.contentView = nil;
+ _host = nil;
+ // Whatever the old block had left to say goes with it: held back, it would be replayed to the next
+ // event emitter as the outcome of the place that took its seat.
+ _pendingAppearance = nil;
+ _pendingOutcome = nil;
+}
+
+@end
+
+Class MindboxEmbeddedBlockViewCls(void)
+{
+ return MindboxEmbeddedBlockViewComponentView.class;
+}
diff --git a/ios/MindboxJsDelivery.h b/ios/MindboxJsDelivery.h
deleted file mode 100644
index aa5b605..0000000
--- a/ios/MindboxJsDelivery.h
+++ /dev/null
@@ -1,17 +0,0 @@
-//
-// MindboxJsDelivery.h
-// MindboxSdk
-//
-// Created by Nikolay Seleznev on 15.10.2021.
-// Copyright © 2021 Facebook. All rights reserved.
-//
-
-#import
-#import
-
-@interface MindboxJsDelivery : RCTEventEmitter
-
-+ (void)emitEvent:(UNNotificationResponse *)response;
-
-+ (void)sendInappEvent:(NSString *)eventName eventId:(NSString *)eventId url:(NSString *)clickUrl payload:(NSString *)payload;
-@end
diff --git a/ios/MindboxJsDelivery.m b/ios/MindboxJsDelivery.m
deleted file mode 100644
index 15c3171..0000000
--- a/ios/MindboxJsDelivery.m
+++ /dev/null
@@ -1,148 +0,0 @@
-//
-// MindboxJsDelivery.m
-// MindboxSdk
-//
-// Created by Nikolay Seleznev on 15.10.2021.
-// Copyright © 2021 Facebook. All rights reserved.
-//
-
-#import "MindboxJsDelivery.h"
-
-#import
-
-@implementation MindboxJsDelivery
-
-RCT_EXPORT_MODULE();
-
-static bool hasListeners = NO;
-static NSDictionary *storedEventDetails;
-
-- (NSArray *)supportedEvents {
- return @[@"pushNotificationClicked", @"Click", @"Dismiss"];
-}
-
-- (void)dealloc {
- [[NSNotificationCenter defaultCenter] removeObserver:self];
-}
-
-- (void)startObserving {
- hasListeners = YES;
-
- [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(emitEventInternal:) name:@"event-emitted" object:nil];
-
- [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(inappActionReceived:)name:@"MindboxInappAction" object:nil];
-
- if (storedEventDetails != NULL) {
- [[NSNotificationCenter defaultCenter] postNotificationName:@"event-emitted" object:self userInfo:storedEventDetails];
- }
-}
-
-- (void)stopObserving {
- hasListeners = NO;
-
- [[NSNotificationCenter defaultCenter] removeObserver:self];
-
- if (storedEventDetails != NULL) {
- storedEventDetails = NULL;
- }
-}
-
-- (void)emitEventInternal:(NSNotification *)notification {
- NSArray *eventDetails = [notification.userInfo valueForKey:@"detail"];
- NSString *eventName = [eventDetails objectAtIndex:0];
- NSString *actionIdentifier = [eventDetails objectAtIndex:1];
- NSDictionary *userInfo = [eventDetails objectAtIndex:2];
- NSString *clickUrl = @"";
- NSString *pushPayload = @"";
-
- if ([actionIdentifier isEqual:UNNotificationDefaultActionIdentifier]) {
- clickUrl = [userInfo objectForKey:@"clickUrl"];
- pushPayload = [userInfo objectForKey:@"payload"];
-
- if ([clickUrl length] == 0) {
- NSDictionary *aps = [userInfo objectForKey:@"aps"];
- clickUrl = [aps objectForKey:@"clickUrl"];
- pushPayload = [aps objectForKey:@"payload"];
- }
- } else {
- NSPredicate *predicate = [NSPredicate predicateWithFormat:@"uniqueKey == %@", actionIdentifier];
- NSArray *filteredArray = [[userInfo objectForKey:@"buttons"] filteredArrayUsingPredicate:predicate];
-
- if (filteredArray.count > 0) {
- clickUrl = [filteredArray.firstObject objectForKey:@"url"];
- }
-
- if ([clickUrl length] == 0) {
- NSDictionary *aps = [userInfo objectForKey:@"aps"];
- NSArray *apsButtons = [aps objectForKey:@"buttons"];
- filteredArray = [apsButtons filteredArrayUsingPredicate:predicate];
- if (filteredArray.count > 0) {
- clickUrl = [filteredArray.firstObject objectForKey:@"url"];
- }
- }
-
- pushPayload = [userInfo objectForKey:@"payload"];
- if ([pushPayload length] == 0) {
- NSDictionary *aps = [userInfo objectForKey:@"aps"];
- pushPayload = [aps objectForKey:@"payload"];
- }
- }
-
- NSDictionary *dict = @{
- @"pushUrl": clickUrl ? clickUrl : [NSNull null],
- @"pushPayload": pushPayload ? pushPayload : [NSNull null]
- };
- NSError *error;
- NSData *jsonData = [NSJSONSerialization dataWithJSONObject:dict options:NSJSONWritingPrettyPrinted error:&error];
- NSString *jsonString = [[NSString alloc] initWithData:jsonData encoding:NSUTF8StringEncoding];
-
- [self sendEventWithName:eventName body:jsonString];
-
- if (storedEventDetails != NULL) {
- storedEventDetails = NULL;
- }
-}
-
-+ (void)emitEvent:(UNNotificationResponse *)response {
- NSDictionary *userInfo = response.notification.request.content.userInfo;
- NSString *name = @"pushNotificationClicked";
- NSString *actionIdentifier = response.actionIdentifier;
- NSDictionary *eventDetails = @{@"detail":@[name,actionIdentifier,userInfo]};
- if (hasListeners) {
- [[NSNotificationCenter defaultCenter] postNotificationName:@"event-emitted" object:self userInfo:eventDetails];
- } else {
- storedEventDetails = eventDetails;
- }
-}
-
-+ (void)sendInappEvent:(NSString *)eventName eventId:(NSString *)eventId url:(NSString *)clickUrl payload:(NSString *)payload {
- if (hasListeners) {
- NSMutableDictionary *bodyDict = [[NSMutableDictionary alloc] init];
- bodyDict[@"id"] = eventId;
-
- if (clickUrl != nil) {
- bodyDict[@"redirectUrl"] = clickUrl;
- }
-
- if (payload != nil) {
- bodyDict[@"payload"] = payload;
- }
-
- NSDictionary *userInfo = @{
- @"eventName": eventName,
- @"body": bodyDict
- };
-
- [[NSNotificationCenter defaultCenter] postNotificationName:@"MindboxInappAction"
- object:self
- userInfo:userInfo];
- }
-}
-
-- (void)inappActionReceived:(NSNotification *)notification {
- NSString *eventName = notification.userInfo[@"eventName"];
- NSDictionary *body = notification.userInfo[@"body"];
- [self sendEventWithName:eventName body:body];
-}
-
-@end
diff --git a/ios/MindboxJsDelivery.swift b/ios/MindboxJsDelivery.swift
new file mode 100644
index 0000000..13ebba1
--- /dev/null
+++ b/ios/MindboxJsDelivery.swift
@@ -0,0 +1,63 @@
+import Foundation
+import UserNotifications
+
+@objc(MindboxJsDelivery)
+public final class MindboxJsDelivery: NSObject {
+
+ @objc public static func emitEvent(_ response: UNNotificationResponse) {
+ let userInfo = response.notification.request.content.userInfo
+ let actionIdentifier = response.actionIdentifier
+ let pushUrl = resolvePushUrl(userInfo: userInfo, actionIdentifier: actionIdentifier)
+ let pushPayload = resolvePushPayload(userInfo: userInfo)
+ MindboxSdkImpl.emitPushClick(pushUrl: pushUrl, pushPayload: pushPayload)
+ }
+
+ @objc public static func sendInappEvent(_ eventName: String, eventId: String, url: String?, payload: String?) {
+ switch eventName {
+ case "Click":
+ MindboxSdkImpl.emitInAppClick(id: eventId, redirectUrl: url, payload: payload)
+ case "Dismiss":
+ MindboxSdkImpl.emitInAppDismiss(id: eventId)
+ default:
+ break
+ }
+ }
+
+ private static func resolvePushUrl(userInfo: [AnyHashable: Any], actionIdentifier: String) -> String {
+ if actionIdentifier == UNNotificationDefaultActionIdentifier {
+ return readString(userInfo: userInfo, key: "clickUrl")
+ ?? readString(userInfo: readDictionary(userInfo: userInfo, key: "aps"), key: "clickUrl")
+ ?? ""
+ }
+ let buttonUrl = readButtonUrl(userInfo: userInfo, uniqueKey: actionIdentifier)
+ if let buttonUrl = buttonUrl, !buttonUrl.isEmpty {
+ return buttonUrl
+ }
+ let aps = readDictionary(userInfo: userInfo, key: "aps")
+ return readButtonUrl(userInfo: aps, uniqueKey: actionIdentifier) ?? ""
+ }
+
+ private static func resolvePushPayload(userInfo: [AnyHashable: Any]) -> String {
+ return readString(userInfo: userInfo, key: "payload")
+ ?? readString(userInfo: readDictionary(userInfo: userInfo, key: "aps"), key: "payload")
+ ?? ""
+ }
+
+ private static func readButtonUrl(userInfo: [AnyHashable: Any], uniqueKey: String) -> String? {
+ guard let buttons = userInfo["buttons"] as? [[String: Any]] else {
+ return nil
+ }
+ return buttons.first(where: { ($0["uniqueKey"] as? String) == uniqueKey })?["url"] as? String
+ }
+
+ private static func readDictionary(userInfo: [AnyHashable: Any], key: String) -> [AnyHashable: Any] {
+ guard let dictionary = userInfo[key] as? [AnyHashable: Any] else {
+ return [:]
+ }
+ return dictionary
+ }
+
+ private static func readString(userInfo: [AnyHashable: Any], key: String) -> String? {
+ return userInfo[key] as? String
+ }
+}
diff --git a/ios/MindboxSdk-Bridging-Header.h b/ios/MindboxSdk-Bridging-Header.h
deleted file mode 100644
index 8992b22..0000000
--- a/ios/MindboxSdk-Bridging-Header.h
+++ /dev/null
@@ -1,3 +0,0 @@
-#import
-#import
-#import
diff --git a/ios/MindboxSdk.m b/ios/MindboxSdk.m
deleted file mode 100644
index 2ebf228..0000000
--- a/ios/MindboxSdk.m
+++ /dev/null
@@ -1,31 +0,0 @@
-#import
-
-@interface RCT_EXTERN_MODULE(MindboxSdk, NSObject)
-
-RCT_EXTERN_METHOD(initialize:(NSString)payloadString resolve:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject)
-
-RCT_EXTERN_METHOD(getDeviceUUID:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject)
-
-RCT_EXTERN_METHOD(getAPNSToken:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject)
-
-RCT_EXTERN_METHOD(updateAPNSToken:(NSString)token resolve:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject)
-
-RCT_EXTERN_METHOD(executeAsyncOperation:(NSString)operationSystemName operationBody:(NSString)operationBody resolve:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject)
-
-RCT_EXTERN_METHOD(executeSyncOperation:(NSString)operationSystemName operationBody:(NSString)operationBody resolve:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject)
-
-RCT_EXTERN_METHOD(registerCallbacks:(NSArray)callbacks)
-
-RCT_EXTERN_METHOD(setLogLevel:(NSInteger)level)
-
-RCT_EXTERN_METHOD(getSdkVersion:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject)
-
-RCT_EXTERN_METHOD(pushDelivered:(NSString)uniqKey)
-
-RCT_EXTERN_METHOD(refreshNotificationPermissionStatus)
-
-RCT_EXTERN_METHOD(writeNativeLog:(NSString)message level:(NSInteger)level)
-
-RCT_EXTERN_METHOD(getTokens:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject)
-
-@end
diff --git a/ios/MindboxSdk.mm b/ios/MindboxSdk.mm
new file mode 100644
index 0000000..453dcde
--- /dev/null
+++ b/ios/MindboxSdk.mm
@@ -0,0 +1,116 @@
+#import
+
+#if __has_include("MindboxSdk-Swift.h")
+#import "MindboxSdk-Swift.h"
+#elif __has_include()
+#import
+#else
+#error "MindboxSdk-Swift.h not found. Ensure Swift sources are included in the MindboxSdk pod target."
+#endif
+
+#if __has_include()
+#import
+#elif __has_include("MindboxSdkSpec.h")
+#import "MindboxSdkSpec.h"
+#else
+#error "MindboxSdkSpec.h not found. Ensure the React Native codegen spec has been generated and the New Architecture/codegen integration is enabled"
+#endif
+
+@interface MindboxSdk : NativeMindboxSdkSpecBase
+@end
+
+@implementation MindboxSdk {
+ MindboxSdkImpl *_impl;
+}
+
+RCT_EXPORT_MODULE(MindboxSdk)
+
++ (BOOL)requiresMainQueueSetup {
+ return YES;
+}
+
+- (instancetype)init {
+ self = [super init];
+ if (self) {
+ _impl = [MindboxSdkImpl new];
+ __weak MindboxSdk *weakSelf = self;
+ _impl.eventEmitHandler = ^(NSString *eventName, NSDictionary *body) {
+ MindboxSdk *strongSelf = weakSelf;
+ if (!strongSelf) return;
+ if ([eventName isEqualToString:@"onPushNotificationClicked"]) {
+ [strongSelf emitOnPushNotificationClicked:body];
+ } else if ([eventName isEqualToString:@"onInAppClick"]) {
+ [strongSelf emitOnInAppClick:body];
+ } else if ([eventName isEqualToString:@"onInAppDismiss"]) {
+ [strongSelf emitOnInAppDismiss:body];
+ }
+ };
+ }
+ return self;
+}
+
+- (void)initialize:(NSString *)payloadString
+ resolve:(RCTPromiseResolveBlock)resolve
+ reject:(RCTPromiseRejectBlock)reject {
+ [_impl initialize:payloadString resolve:resolve reject:reject];
+}
+
+- (void)registerCallbacks:(NSArray *)callbacks {
+ [_impl registerCallbacks:callbacks];
+}
+
+- (void)getDeviceUUID:(RCTPromiseResolveBlock)resolve
+ reject:(RCTPromiseRejectBlock)reject {
+ [_impl getDeviceUUID:resolve reject:reject];
+}
+
+- (void)getTokens:(RCTPromiseResolveBlock)resolve
+ reject:(RCTPromiseRejectBlock)reject {
+ [_impl getTokens:resolve reject:reject];
+}
+
+- (void)executeAsyncOperation:(NSString *)operationSystemName
+ operationBody:(NSString *)operationBody
+ resolve:(RCTPromiseResolveBlock)resolve
+ reject:(RCTPromiseRejectBlock)reject {
+ [_impl executeAsyncOperation:operationSystemName operationBody:operationBody resolve:resolve reject:reject];
+}
+
+- (void)executeSyncOperation:(NSString *)operationSystemName
+ operationBody:(NSString *)operationBody
+ resolve:(RCTPromiseResolveBlock)resolve
+ reject:(RCTPromiseRejectBlock)reject {
+ [_impl executeSyncOperation:operationSystemName operationBody:operationBody resolve:resolve reject:reject];
+}
+
+- (void)onPushClickedIsRegistered:(BOOL)isRegistered {
+ [_impl onPushClickedIsRegistered:isRegistered];
+}
+
+- (void)setLogLevel:(double)level {
+ [_impl setLogLevel:level];
+}
+
+- (void)getSdkVersion:(RCTPromiseResolveBlock)resolve
+ reject:(RCTPromiseRejectBlock)reject {
+ [_impl getSdkVersion:resolve reject:reject];
+}
+
+- (void)pushDelivered:(NSString *)uniqKey {
+ [_impl pushDelivered:uniqKey];
+}
+
+- (void)refreshNotificationPermissionStatus {
+ [_impl refreshNotificationPermissionStatus];
+}
+
+- (void)writeNativeLog:(NSString *)message
+ logLevel:(double)logLevel {
+ [_impl writeNativeLog:message logLevel:logLevel];
+}
+
+- (std::shared_ptr)getTurboModule:(const facebook::react::ObjCTurboModule::InitParams &)params {
+ return std::make_shared(params);
+}
+
+@end
diff --git a/ios/MindboxSdk.swift b/ios/MindboxSdk.swift
deleted file mode 100644
index 8779d91..0000000
--- a/ios/MindboxSdk.swift
+++ /dev/null
@@ -1,220 +0,0 @@
-import Mindbox
-import MindboxLogger
-
-enum CustomError: Error {
- case tokenAPNSisNull
-}
-
-extension CustomError: LocalizedError {
- public var errorDescription: String? {
- switch self {
- case .tokenAPNSisNull:
- return NSLocalizedString("APNS token cannot be nullable", comment: "APNS token is null")
- }
- }
-}
-
-struct PayloadData: Codable {
- var domain: String
- var endpointId: String
- var subscribeCustomerIfCreated: Bool?
- var shouldCreateCustomer: Bool?
- var previousInstallId: String?
- var previousUuid: String?
-}
-
-@objc(MindboxSdk)
-class MindboxSdk: NSObject {
-
- private var urlInappDelegate: URLInappMessageDelegate?
- private var copyInappDelegate: CopyInappMessageDelegate?
- private var emptyInappDelegate: InAppMessagesDelegate?
- private var customClass: InAppMessagesDelegate?
- private var compositeDelegate: CompositeInappMessageDelegate?
-
- @objc
- static func requiresMainQueueSetup() -> Bool {
- return true
- }
-
- @objc(initialize:resolve:rejecter:)
- func initialize(_ payloadString: String, resolve: @escaping RCTPromiseResolveBlock, rejecter reject: RCTPromiseRejectBlock) -> Void {
- do {
- let payload = try JSONDecoder().decode(PayloadData.self, from: payloadString.data(using: .utf8)!)
-
- let configuration = try MBConfiguration(
- endpoint: payload.endpointId,
- domain: payload.domain,
- previousInstallationId: payload.previousInstallId,
- previousDeviceUUID: payload.previousUuid,
- subscribeCustomerIfCreated: payload.subscribeCustomerIfCreated ?? false,
- shouldCreateCustomer: payload.shouldCreateCustomer ?? true
- )
-
- Mindbox.shared.initialization(configuration: configuration)
-
- resolve(true)
- } catch {
- reject("Error", error.localizedDescription, error)
- }
- }
-
- @objc(getDeviceUUID:rejecter:)
- func getDeviceUUID(_ resolve: @escaping RCTPromiseResolveBlock, rejecter reject: RCTPromiseRejectBlock) -> Void {
- Mindbox.shared.getDeviceUUID{
- deviceUUID in resolve(deviceUUID)
- }
- }
-
- @objc(getAPNSToken:rejecter:)
- func getAPNSToken(_ resolve: @escaping RCTPromiseResolveBlock, rejecter reject: RCTPromiseRejectBlock) -> Void {
- Mindbox.shared.getAPNSToken{
- ApnsToken in resolve(ApnsToken)
- }
- }
-
- @objc(getTokens:rejecter:)
- func getTokens(_ resolve: @escaping RCTPromiseResolveBlock, rejecter reject: RCTPromiseRejectBlock) -> Void {
- Mindbox.shared.getAPNSToken{
- ApnsToken in resolve("{\"APNS\":\"\(ApnsToken)\"}")
- }
- }
-
- @objc func registerCallbacks(_ callbacks: [String]) {
- var cb = [InAppMessagesDelegate]()
-
- for callback in callbacks {
- switch callback {
- case "urlInAppCallback":
- urlInappDelegate = URLInappDelegate()
- if let urlInappDelegate = urlInappDelegate {
- cb.append(urlInappDelegate)
- }
- case "copyPayloadInAppCallback":
- copyInappDelegate = CopyInappDelegate()
- if let copyInappDelegate = copyInappDelegate {
- cb.append(copyInappDelegate)
- }
- case "emptyInAppCallback":
- emptyInappDelegate = EmptyInappDelegate()
- if let emptyInappDelegate = emptyInappDelegate {
- cb.append(emptyInappDelegate)
- }
- default:
- customClass = CustomInappDelegate()
- if let customClass = customClass {
- cb.append(customClass)
- }
- }
- }
-
- compositeDelegate = CompositeInappDelegate()
- compositeDelegate?.delegates = cb
- Mindbox.shared.inAppMessagesDelegate = compositeDelegate
- }
-
- @objc(updateAPNSToken:resolve:rejecter:)
- func updateAPNSToken(_ token: String, resolve: @escaping RCTPromiseResolveBlock, rejecter reject: RCTPromiseRejectBlock) -> Void {
- do {
- guard let tokenData = token.data(using: .utf8) else { throw CustomError.tokenAPNSisNull }
-
- Mindbox.shared.apnsTokenUpdate(deviceToken: tokenData)
-
- resolve(true)
- } catch {
- reject("Error", error.localizedDescription, error)
- }
- }
-
- @objc(executeAsyncOperation:operationBody:resolve:rejecter:)
- func executeAsyncOperation(_ operationSystemName: String, operationBody: String, resolve: @escaping RCTPromiseResolveBlock, rejecter reject: @escaping RCTPromiseRejectBlock) -> Void {
- Mindbox.shared.executeAsyncOperation(operationSystemName: operationSystemName, json: operationBody)
- resolve(true)
- }
-
- @objc(executeSyncOperation:operationBody:resolve:rejecter:)
- func executeSyncOperation(_ operationSystemName: String, operationBody: String, resolve: @escaping RCTPromiseResolveBlock, rejecter reject: @escaping RCTPromiseRejectBlock) -> Void {
- Mindbox.shared.executeSyncOperation(operationSystemName: operationSystemName, json: operationBody) { result in
- switch result {
- case .success(let response):
- resolve(response.createJSON())
- case .failure(let error):
- resolve(error.createJSON())
- }
- }
- }
-
- @objc
- static func moduleName() -> String {
- return "MindboxSdk"
- }
-
- @objc
- func constantsToExport() -> [AnyHashable: Any] {
- return [:]
- }
-
- @objc(setLogLevel:)
- func setLogLevel(_ level: Int) -> Void {
- switch (level) {
- case 0:
- Mindbox.logger.logLevel = .debug
- case 1:
- Mindbox.logger.logLevel = .info
- case 2:
- Mindbox.logger.logLevel = .default
- case 3:
- Mindbox.logger.logLevel = .error
- case 4:
- Mindbox.logger.logLevel = .fault
- default:
- Mindbox.logger.logLevel = .none
- }
- }
-
- @objc
- func getSdkVersion() -> String {
- return Mindbox.shared.sdkVersion
- }
-
- @objc(getSdkVersion:rejecter:)
- func getSdkVersion(_ resolve: RCTPromiseResolveBlock, rejecter reject: RCTPromiseRejectBlock) {
- do {
- resolve(Mindbox.shared.sdkVersion)
- } catch {
- reject("Error", error.localizedDescription, error)
- }
- }
-
- @objc(pushDelivered:)
- func pushDelivered(_ uniqKey: String) {
- Mindbox.shared.pushDelivered(uniqueKey: uniqKey)
- }
-
- @objc
- func refreshNotificationPermissionStatus() {
- Mindbox.shared.refreshNotificationPermissionStatus()
- }
-
- @objc
- func writeNativeLog(_ message: String, level: Int) {
-
- let logLevel: LogLevel
-
- switch level {
- case 0:
- logLevel = .debug
- case 1:
- logLevel = .info
- case 2:
- logLevel = .default
- case 3:
- logLevel = .error
- case 4:
- logLevel = .fault
- default:
- logLevel = .none
- }
- Mindbox.logger.log(level: logLevel, message: message)
- }
-}
diff --git a/ios/MindboxSdk.xcodeproj/project.pbxproj b/ios/MindboxSdk.xcodeproj/project.pbxproj
index 4fa4356..6205b75 100644
--- a/ios/MindboxSdk.xcodeproj/project.pbxproj
+++ b/ios/MindboxSdk.xcodeproj/project.pbxproj
@@ -7,9 +7,11 @@
objects = {
/* Begin PBXBuildFile section */
- 61249CFA2719673B00FC4033 /* MindboxJsDelivery.m in Sources */ = {isa = PBXBuildFile; fileRef = 61249CF92719673B00FC4033 /* MindboxJsDelivery.m */; };
+ 61249CFA2719673B00FC4033 /* MindboxJsDelivery.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61249CF92719673B00FC4033 /* MindboxJsDelivery.swift */; };
D0F7B0012B1C4A1A00A1B2C3 /* MindboxJsDeliveryBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0F7B0002B1C4A1A00A1B2C3 /* MindboxJsDeliveryBridge.swift */; };
- F4FF95D7245B92E800C19C63 /* MindboxSdk.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4FF95D6245B92E800C19C63 /* MindboxSdk.swift */; };
+ E0A1A1A12B2D2D2D00A1B2C3 /* MindboxSdk.mm in Sources */ = {isa = PBXBuildFile; fileRef = E0A1A1A02B2D2D2D00A1B2C3 /* MindboxSdk.mm */; };
+ E0A1A1A32B2D2D2D00A1B2C3 /* MindboxSdkNewArchGuard.mm in Sources */ = {isa = PBXBuildFile; fileRef = E0A1A1A22B2D2D2D00A1B2C3 /* MindboxSdkNewArchGuard.mm */; };
+ F4FF95D7245B92E800C19C63 /* MindboxSdkImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4FF95D6245B92E800C19C63 /* MindboxSdkImpl.swift */; };
/* End PBXBuildFile section */
/* Begin PBXCopyFilesBuildPhase section */
@@ -26,12 +28,11 @@
/* Begin PBXFileReference section */
134814201AA4EA6300B7C361 /* libMindboxSdk.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = libMindboxSdk.a; sourceTree = BUILT_PRODUCTS_DIR; };
- 61249CF82719673B00FC4033 /* MindboxJsDelivery.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MindboxJsDelivery.h; sourceTree = ""; };
- 61249CF92719673B00FC4033 /* MindboxJsDelivery.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MindboxJsDelivery.m; sourceTree = ""; };
- B3E7B5891CC2AC0600A0062D /* MindboxSdk.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = MindboxSdk.m; sourceTree = ""; };
+ 61249CF92719673B00FC4033 /* MindboxJsDelivery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MindboxJsDelivery.swift; sourceTree = ""; };
D0F7B0002B1C4A1A00A1B2C3 /* MindboxJsDeliveryBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MindboxJsDeliveryBridge.swift; sourceTree = ""; };
- F4FF95D5245B92E700C19C63 /* MindboxSdk-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "MindboxSdk-Bridging-Header.h"; sourceTree = ""; };
- F4FF95D6245B92E800C19C63 /* MindboxSdk.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MindboxSdk.swift; sourceTree = ""; };
+ E0A1A1A02B2D2D2D00A1B2C3 /* MindboxSdk.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = MindboxSdk.mm; sourceTree = ""; };
+ E0A1A1A22B2D2D2D00A1B2C3 /* MindboxSdkNewArchGuard.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = MindboxSdkNewArchGuard.mm; sourceTree = ""; };
+ F4FF95D6245B92E800C19C63 /* MindboxSdkImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MindboxSdkImpl.swift; sourceTree = ""; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -56,12 +57,11 @@
58B511D21A9E6C8500147676 = {
isa = PBXGroup;
children = (
- 61249CF82719673B00FC4033 /* MindboxJsDelivery.h */,
- 61249CF92719673B00FC4033 /* MindboxJsDelivery.m */,
+ 61249CF92719673B00FC4033 /* MindboxJsDelivery.swift */,
D0F7B0002B1C4A1A00A1B2C3 /* MindboxJsDeliveryBridge.swift */,
- F4FF95D6245B92E800C19C63 /* MindboxSdk.swift */,
- B3E7B5891CC2AC0600A0062D /* MindboxSdk.m */,
- F4FF95D5245B92E700C19C63 /* MindboxSdk-Bridging-Header.h */,
+ E0A1A1A22B2D2D2D00A1B2C3 /* MindboxSdkNewArchGuard.mm */,
+ F4FF95D6245B92E800C19C63 /* MindboxSdkImpl.swift */,
+ E0A1A1A02B2D2D2D00A1B2C3 /* MindboxSdk.mm */,
134814211AA4EA7D00B7C361 /* Products */,
);
sourceTree = "";
@@ -123,9 +123,11 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
- 61249CFA2719673B00FC4033 /* MindboxJsDelivery.m in Sources */,
+ 61249CFA2719673B00FC4033 /* MindboxJsDelivery.swift in Sources */,
D0F7B0012B1C4A1A00A1B2C3 /* MindboxJsDeliveryBridge.swift in Sources */,
- F4FF95D7245B92E800C19C63 /* MindboxSdk.swift in Sources */,
+ E0A1A1A12B2D2D2D00A1B2C3 /* MindboxSdk.mm in Sources */,
+ E0A1A1A32B2D2D2D00A1B2C3 /* MindboxSdkNewArchGuard.mm in Sources */,
+ F4FF95D7245B92E800C19C63 /* MindboxSdkImpl.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -238,7 +240,6 @@
OTHER_LDFLAGS = "-ObjC";
PRODUCT_NAME = MindboxSdk;
SKIP_INSTALL = YES;
- SWIFT_OBJC_BRIDGING_HEADER = "MindboxSdk-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
};
@@ -257,7 +258,6 @@
OTHER_LDFLAGS = "-ObjC";
PRODUCT_NAME = MindboxSdk;
SKIP_INSTALL = YES;
- SWIFT_OBJC_BRIDGING_HEADER = "MindboxSdk-Bridging-Header.h";
SWIFT_VERSION = 5.0;
};
name = Release;
diff --git a/ios/MindboxSdkImpl.swift b/ios/MindboxSdkImpl.swift
new file mode 100644
index 0000000..c712d03
--- /dev/null
+++ b/ios/MindboxSdkImpl.swift
@@ -0,0 +1,217 @@
+import Foundation
+import Mindbox
+import MindboxLogger
+
+struct PayloadData: Codable {
+ var domain: String
+ var endpointId: String
+ var subscribeCustomerIfCreated: Bool?
+ var shouldCreateCustomer: Bool?
+ var previousInstallId: String?
+ var previousUuid: String?
+ var operationsDomain: String?
+}
+
+public typealias ResolveBlock = (Any?) -> Void
+public typealias RejectBlock = (String?, String?, NSError?) -> Void
+public typealias EventEmitHandler = (_ eventName: String, _ body: [String: String]) -> Void
+
+@objc(MindboxSdkImpl)
+public final class MindboxSdkImpl: NSObject {
+
+ private var urlInappDelegate: URLInappMessageDelegate?
+ private var copyInappDelegate: CopyInappMessageDelegate?
+ private var emptyInappDelegate: InAppMessagesDelegate?
+ private var customClass: InAppMessagesDelegate?
+ private var compositeDelegate: CompositeInappMessageDelegate?
+
+ @objc var isPushListenerRegistered: Bool = false
+ @objc static var pendingPushPayload: [String: String]?
+ @objc public var eventEmitHandler: EventEmitHandler?
+
+ private static let stateQueue = DispatchQueue(label: "com.mindboxsdk.MindboxSdkImpl.state")
+ private static weak var activeInstance: MindboxSdkImpl?
+
+ @objc public override init() {
+ super.init()
+ MindboxSdkImpl.stateQueue.sync {
+ MindboxSdkImpl.activeInstance = self
+ }
+ }
+
+ @objc public func initialize(_ payloadString: String, resolve: @escaping ResolveBlock, reject: @escaping RejectBlock) {
+ do {
+ guard let payloadData = payloadString.data(using: .utf8) else {
+ reject("Error", "Initialization payload must be UTF-8 encoded", nil)
+ return
+ }
+ let payload = try JSONDecoder().decode(PayloadData.self, from: payloadData)
+ let configuration = try MBConfiguration(
+ endpoint: payload.endpointId,
+ domain: payload.domain,
+ operationsDomain: payload.operationsDomain,
+ previousInstallationId: payload.previousInstallId,
+ previousDeviceUUID: payload.previousUuid,
+ subscribeCustomerIfCreated: payload.subscribeCustomerIfCreated ?? false,
+ shouldCreateCustomer: payload.shouldCreateCustomer ?? true
+ )
+ Mindbox.shared.initialization(configuration: configuration)
+ resolve(true)
+ } catch {
+ reject("Error", error.localizedDescription, error as NSError)
+ }
+ }
+
+ @objc public func getDeviceUUID(_ resolve: @escaping ResolveBlock, reject: @escaping RejectBlock) {
+ Mindbox.shared.getDeviceUUID { deviceUUID in
+ resolve(deviceUUID)
+ }
+ }
+
+ @objc public func getTokens(_ resolve: @escaping ResolveBlock, reject: @escaping RejectBlock) {
+ Mindbox.shared.getAPNSToken { apnsToken in
+ do {
+ let tokens: [String: String] = ["APNS": apnsToken]
+ let data = try JSONSerialization.data(withJSONObject: tokens)
+ guard let json = String(data: data, encoding: .utf8) else {
+ reject("Error", "Failed to encode APNS token payload", nil)
+ return
+ }
+ resolve(json)
+ } catch {
+ reject("Error", error.localizedDescription, error as NSError)
+ }
+ }
+ }
+
+ @objc public func registerCallbacks(_ callbacks: [String]) {
+ var cb = [InAppMessagesDelegate]()
+ for callback in callbacks {
+ switch callback {
+ case "urlInAppCallback":
+ urlInappDelegate = URLInappDelegate()
+ if let urlInappDelegate = urlInappDelegate {
+ cb.append(urlInappDelegate)
+ }
+ case "copyPayloadInAppCallback":
+ copyInappDelegate = CopyInappDelegate()
+ if let copyInappDelegate = copyInappDelegate {
+ cb.append(copyInappDelegate)
+ }
+ case "emptyInAppCallback":
+ emptyInappDelegate = EmptyInappDelegate()
+ if let emptyInappDelegate = emptyInappDelegate {
+ cb.append(emptyInappDelegate)
+ }
+ default:
+ customClass = CustomInappDelegate()
+ if let customClass = customClass {
+ cb.append(customClass)
+ }
+ }
+ }
+ compositeDelegate = CompositeInappDelegate()
+ compositeDelegate?.delegates = cb
+ Mindbox.shared.inAppMessagesDelegate = compositeDelegate
+ }
+
+ @objc public func executeAsyncOperation(_ operationSystemName: String, operationBody: String, resolve: @escaping ResolveBlock, reject: @escaping RejectBlock) {
+ Mindbox.shared.executeAsyncOperation(operationSystemName: operationSystemName, json: operationBody)
+ resolve(true)
+ }
+
+ @objc public func executeSyncOperation(_ operationSystemName: String, operationBody: String, resolve: @escaping ResolveBlock, reject: @escaping RejectBlock) {
+ Mindbox.shared.executeSyncOperation(operationSystemName: operationSystemName, json: operationBody) { result in
+ switch result {
+ case .success(let response):
+ resolve(response.createJSON())
+ case .failure(let error):
+ resolve(error.createJSON())
+ }
+ }
+ }
+
+ @objc public func onPushClickedIsRegistered(_ isRegistered: Bool) {
+ let pendingPayload: [String: String]? = MindboxSdkImpl.stateQueue.sync {
+ isPushListenerRegistered = isRegistered
+ guard isRegistered, let pendingPayload = MindboxSdkImpl.pendingPushPayload else {
+ return nil
+ }
+ MindboxSdkImpl.pendingPushPayload = nil
+ return pendingPayload
+ }
+ if let pendingPayload = pendingPayload {
+ eventEmitHandler?("onPushNotificationClicked", pendingPayload)
+ }
+ }
+
+ @objc public func setLogLevel(_ level: Double) {
+ switch Int(level) {
+ case 0: Mindbox.logger.logLevel = .debug
+ case 1: Mindbox.logger.logLevel = .info
+ case 2: Mindbox.logger.logLevel = .default
+ case 3: Mindbox.logger.logLevel = .error
+ case 4: Mindbox.logger.logLevel = .fault
+ default: Mindbox.logger.logLevel = .none
+ }
+ }
+
+ @objc public func getSdkVersion(_ resolve: @escaping ResolveBlock, reject: @escaping RejectBlock) {
+ resolve(Mindbox.shared.sdkVersion)
+ }
+
+ @objc public func pushDelivered(_ uniqKey: String) {
+ Mindbox.shared.pushDelivered(uniqueKey: uniqKey)
+ }
+
+ @objc public func refreshNotificationPermissionStatus() {
+ Mindbox.shared.refreshNotificationPermissionStatus()
+ }
+
+ @objc public func writeNativeLog(_ message: String, logLevel: Double) {
+ let mappedLogLevel: LogLevel
+ switch Int(logLevel) {
+ case 0: mappedLogLevel = .debug
+ case 1: mappedLogLevel = .info
+ case 2: mappedLogLevel = .default
+ case 3: mappedLogLevel = .error
+ case 4: mappedLogLevel = .fault
+ default: mappedLogLevel = .none
+ }
+ Mindbox.logger.log(level: mappedLogLevel, message: message)
+ }
+
+ @objc static func emitPushClick(pushUrl: String, pushPayload: String) {
+ let payload: [String: String] = [
+ "pushUrl": pushUrl,
+ "pushPayload": pushPayload
+ ]
+ let instance: MindboxSdkImpl? = MindboxSdkImpl.stateQueue.sync {
+ guard let activeInstance = MindboxSdkImpl.activeInstance, activeInstance.isPushListenerRegistered else {
+ MindboxSdkImpl.pendingPushPayload = payload
+ return nil
+ }
+ return activeInstance
+ }
+ if let instance = instance {
+ instance.eventEmitHandler?("onPushNotificationClicked", payload)
+ }
+ }
+
+ @objc static func emitInAppClick(id: String, redirectUrl: String?, payload: String?) {
+ guard let instance = MindboxSdkImpl.stateQueue.sync(execute: { MindboxSdkImpl.activeInstance }) else { return }
+ var body: [String: String] = ["id": id]
+ if let redirectUrl = redirectUrl {
+ body["redirectUrl"] = redirectUrl
+ }
+ if let payload = payload {
+ body["payload"] = payload
+ }
+ instance.eventEmitHandler?("onInAppClick", body)
+ }
+
+ @objc static func emitInAppDismiss(id: String) {
+ guard let instance = MindboxSdkImpl.stateQueue.sync(execute: { MindboxSdkImpl.activeInstance }) else { return }
+ instance.eventEmitHandler?("onInAppDismiss", ["id": id])
+ }
+}
diff --git a/ios/MindboxSdkNewArchGuard.mm b/ios/MindboxSdkNewArchGuard.mm
new file mode 100644
index 0000000..3977c30
--- /dev/null
+++ b/ios/MindboxSdkNewArchGuard.mm
@@ -0,0 +1,3 @@
+#if !defined(RCT_NEW_ARCH_ENABLED) || RCT_NEW_ARCH_ENABLED != 1
+#error "MindboxSdk requires React Native New Architecture. Set RCT_NEW_ARCH_ENABLED=1."
+#endif
diff --git a/package.json b/package.json
index e36c971..2707121 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "mindbox-sdk",
"version": "2.15.0",
- "target-version": "2.15.0",
+ "target-version": "3.0.0-rc",
"description": "SDK for integration React Native mobile apps with Mindbox",
"main": "lib/commonjs/index",
"module": "lib/module/index",
@@ -24,7 +24,7 @@
],
"scripts": {
"test": "jest",
- "typescript": "tsc --noEmit",
+ "typescript": "tsc --noEmit -p tsconfig.build.json",
"lint": "eslint \"**/*.{js,ts,tsx}\"",
"prepare": "bob build",
"release": "release-it",
@@ -48,29 +48,46 @@
"registry": "https://registry.npmjs.org/"
},
"devDependencies": {
- "@react-native-community/eslint-config": "^3.0.0",
+ "@babel/core": "^7.25.2",
+ "@babel/preset-env": "^7.25.3",
+ "@react-native/eslint-config": "0.76.0",
"@release-it/conventional-changelog": "^2.0.0",
"@release-it/keep-a-changelog": "^2.3.0",
- "@types/jest": "^26.0.0",
- "@types/react": "^16.9.19",
- "@types/react-native": "0.62.13",
+ "@types/jest": "^29.5.14",
+ "@types/react": "^18.3.12",
+ "@types/react-test-renderer": "^18.3.0",
+ "babel-jest": "^29.7.0",
"eslint": "^8.19.0",
"eslint-config-prettier": "^8.3.0",
"eslint-plugin-prettier": "^3.1.3",
"husky": "^4.2.5",
- "jest": "^26.0.1",
+ "jest": "^29.7.0",
"lint-staged": "^12.0.2",
"pod-install": "^0.1.0",
"prettier": "^2.0.5",
- "react": "16.13.1",
- "react-native": "0.63.4",
+ "react": "18.3.1",
+ "react-native": "0.76.0",
+ "react-test-renderer": "18.3.1",
"react-native-builder-bob": "^0.18.0",
"release-it": "^14.2.2",
- "typescript": "^4.1.3"
+ "typescript": "^5.0.4"
},
"peerDependencies": {
- "react": "*",
- "react-native": "*"
+ "react": ">=18.0.0",
+ "react-native": ">=0.76.0"
+ },
+ "codegenConfig": {
+ "name": "MindboxSdkSpec",
+ "type": "all",
+ "jsSrcsDir": "src",
+ "android": {
+ "javaPackageName": "com.mindboxsdk"
+ },
+ "ios": {
+ "componentProvider": {
+ "MindboxEmbeddedBlockView": "MindboxEmbeddedBlockViewComponentView"
+ }
+ }
},
"jest": {
"preset": "react-native",
@@ -115,7 +132,10 @@
"eslintConfig": {
"root": true,
"extends": [
- "@react-native-community",
+ "@react-native",
+ "prettier"
+ ],
+ "plugins": [
"prettier"
],
"rules": {
diff --git a/src/MindboxEmbeddedBlock.tsx b/src/MindboxEmbeddedBlock.tsx
new file mode 100644
index 0000000..4362d28
--- /dev/null
+++ b/src/MindboxEmbeddedBlock.tsx
@@ -0,0 +1,235 @@
+import React, { useCallback, useRef, useState } from 'react'
+import { StyleSheet, View } from 'react-native'
+import type { NativeSyntheticEvent, StyleProp, ViewStyle } from 'react-native'
+
+import MindboxEmbeddedBlockNativeView from './MindboxEmbeddedBlockNativeComponent'
+
+/**
+ * How the block occupies its place right now — what the wrapper draws, not what happened.
+ *
+ * The rules behind the decision stay in the native container: the content states, the rule that an
+ * empty place shows no failure, the one that a place taken by loading is a place drawn. RN mirrors
+ * the answer in its layout and nothing more, so every wrapper of the SDK shows the same thing at the
+ * same moment by construction.
+ */
+type Appearance = 'placeholder' | 'content' | 'error' | 'collapsed'
+
+const APPEARANCES: Array = ['placeholder', 'content', 'error', 'collapsed']
+
+/**
+ * Why the place ended up without content.
+ *
+ * Empty today, and an object rather than nothing on purpose: the SDK does not yet tell a timeout from
+ * an empty place from a network failure, and when it does the reason lands here without breaking a
+ * single caller.
+ */
+export type MindboxEmbeddedBlockFailure = {}
+
+export type MindboxEmbeddedBlockProps = {
+ /**
+ * The name of the place from the admin panel. A different name is a different block, built from
+ * scratch in place of the old one.
+ *
+ * Taken exactly as given: nothing is trimmed, so spaces around the name are part of it and keep
+ * the block from matching the place. A name that is empty — or nothing but spaces — resolves to
+ * nothing at all, so the place collapses and reports [onFail]. The component warns about both.
+ */
+ placeSystemName: string
+
+ /**
+ * The height the block occupies while it loads and while it is shown. A place that ends up without
+ * content collapses to zero height and hands the space back.
+ *
+ * Live: a new value resizes a block already on screen in place — the same content, no reload —
+ * exactly as the SwiftUI, Compose and Flutter wrappers behave.
+ *
+ * It has to be a positive number. A block given no space to occupy is never loaded at all — a page
+ * laid out in a zero viewport does not lay itself out again once the space arrives — and reports
+ * neither outcome. The component warns about such a height.
+ */
+ height: number
+
+ /**
+ * How long the block waits to find out what to show, in milliseconds. Covers the wait for the
+ * answer — the config and the targeting — not the whole life of the block: a page that has already
+ * arrived gets its own time to render. When the wait runs out, the block collapses and reports
+ * [onFail].
+ *
+ * The budget is the user's, not the clock's: it ticks only while the screen with the block is
+ * actually looked at (see [active]). Omitted means the SDK's own default of 30 seconds; zero and
+ * negative values mean it too. The value is taken once, when the block is created — the same rule as
+ * in the SwiftUI, Compose and Flutter wrappers — so a new value on a live block is ignored with a
+ * warning. Remount the component (give it a new `key`) to change it.
+ */
+ timeoutMs?: number
+
+ /**
+ * Drawn instead of the SDK shimmer while the block is loading. Fills the whole place, as the native
+ * placeholder does.
+ */
+ placeholder?: React.ReactNode
+
+ /**
+ * Drawn instead of collapsing when the block cannot be shown.
+ *
+ * Applies only to failures: an empty place — one with nothing behind its place system name — always
+ * collapses, so a host cannot fill the space of a block that was never meant to be there.
+ */
+ error?: React.ReactNode
+
+ /** The content is shown. */
+ onLoad?: () => void
+
+ /**
+ * The place ended up without content: the load failed or timed out, or there is nothing behind the
+ * name. An empty place is a normal outcome, not a breakage.
+ */
+ onFail?: (failure: MindboxEmbeddedBlockFailure) => void
+
+ /**
+ * Whether the screen the block stands on is the one being looked at. `true` by default.
+ *
+ * The native container watches its window, and in RN that is not enough: a native stack keeps the
+ * screens below the top one in the window, so leaving a screen never takes the block out of it.
+ * Left alone, the block would spend its whole waiting budget behind another screen and collapse
+ * before the user came back. Pass `useIsFocused()` — or whatever the app's navigation calls it.
+ */
+ active?: boolean
+
+ style?: StyleProp
+}
+
+/**
+ * An embedded Mindbox block.
+ *
+ * The app marks a *place* by its [placeSystemName] and never learns what goes into it — that is the
+ * config's decision, and it can change without an app release. **The host owns the size**: pass the
+ * [height] the block should occupy.
+ *
+ * ```tsx
+ *
+ * ```
+ *
+ * Both outcomes can be customized, the same way as in SwiftUI, Compose and Flutter: [placeholder]
+ * replaces the stock loading shimmer, and [error] opts into showing a failure instead of collapsing.
+ * Both stay ordinary RN nodes, drawn above the native view, so they resolve the context, the theme
+ * and the handlers of the tree the block itself stands in. The wait for an answer is bounded by
+ * [timeoutMs] — 30 seconds unless the host says otherwise.
+ *
+ * The component is a thin layer over the native block: the native view holds the SDK's own container
+ * — with its waiting budget and its web page — and this component only mirrors the container's
+ * decisions in the RN layout.
+ */
+export const MindboxEmbeddedBlock = (props: MindboxEmbeddedBlockProps) => (
+ // A different place is a different block, and everything remembered about the old one has to go
+ // with it — the outcome already delivered, the appearance last shown. Keying the whole component is
+ // what `key(placeSystemName)` does in Compose and `ValueKey` in Flutter; keying nothing would keep
+ // live state pointing at a block that is gone.
+
+)
+
+const Block = ({ placeSystemName, height, timeoutMs, placeholder, error, onLoad, onFail, active = true, style }: MindboxEmbeddedBlockProps) => {
+ // Starts where the native container starts: the space is taken and the loading screen is up. The
+ // block occupies its height right away, not from the container's first report.
+ const [appearance, setAppearance] = useState('placeholder')
+
+ // What of the height actually reaches the layout: live — a new value resizes the block in place —
+ // but never nonsense. A height that is not a positive finite number reserves no space.
+ const blockHeight = Number.isFinite(height) ? Math.max(0, height) : 0
+
+ // Said once, when the block is built: these are creation mistakes, not states to keep reporting.
+ const hasWarnedAboutCreation = useRef(false)
+ if (!hasWarnedAboutCreation.current) {
+ hasWarnedAboutCreation.current = true
+ // A name of nothing but spaces is a missing name, not a padded one, so it is answered first —
+ // and answered without quoting it back, since `The block " "` reads as a typo in the message.
+ if (placeSystemName.trim().length === 0) {
+ console.warn('[MindboxEmbeddedBlock] A block was created without a place system name: there is nothing to resolve by it, so the place collapses and reports onFail.')
+ } else if (placeSystemName.trim() !== placeSystemName) {
+ console.warn(`[MindboxEmbeddedBlock] The block "${placeSystemName}" was given a place system name with spaces around it. The name is used as it is, so it will not match the place from the admin panel.`)
+ }
+ if (blockHeight <= 0) {
+ console.warn(`[MindboxEmbeddedBlock] The block "${placeSystemName}" was created with height ${height}: it reserves no space, so nothing loads and no outcome is reported.`)
+ }
+ }
+
+ // The budget is handed to the container once, when the block is built — a running wait cannot be
+ // re-budgeted, and every wrapper of the SDK keeps the timeout it was built with. Freezing the value
+ // here keeps the native side out of it; the one warning below is what says the new value went
+ // nowhere.
+ const creationTimeoutMs = useRef(timeoutMs).current
+ const hasWarnedAboutTimeout = useRef(false)
+ if (timeoutMs !== creationTimeoutMs && !hasWarnedAboutTimeout.current) {
+ hasWarnedAboutTimeout.current = true
+ console.warn(`[MindboxEmbeddedBlock] The block "${placeSystemName}" keeps the timeout it was created with; the new value is ignored. Remount the component — give it a new key — to change the timeout.`)
+ }
+
+ /** The native side reports where the block stands, so the same outcome can arrive more than once — the host must hear it exactly once. */
+ const deliveredOutcome = useRef<'load' | 'fail' | null>(null)
+
+ const handleAppearanceChange = useCallback((event: NativeSyntheticEvent<{ appearance: string }>) => {
+ const reported = event.nativeEvent.appearance
+ // Tolerant on purpose: a native side newer than this one may report an appearance this version
+ // does not know, and that is no reason to break the block — the last known one stands.
+ if (APPEARANCES.includes(reported)) {
+ setAppearance(reported as Appearance)
+ }
+ }, [])
+
+ const handleLoad = useCallback(() => {
+ if (deliveredOutcome.current === 'load') {
+ return
+ }
+ deliveredOutcome.current = 'load'
+ onLoad?.()
+ }, [onLoad])
+
+ const handleFail = useCallback(() => {
+ if (deliveredOutcome.current === 'fail') {
+ return
+ }
+ deliveredOutcome.current = 'fail'
+ onFail?.({})
+ }, [onFail])
+
+ const overlay = appearance === 'placeholder' ? placeholder : appearance === 'error' ? error : null
+
+ return (
+ // The computed height goes last: a collapsed block gives its space back whatever the host's own
+ // style says. `collapsable` keeps the wrapper — and its clipping — alive on Android.
+
+
+ {/* Nothing to draw is no overlay at all. `box-none` keeps the empty parts of it transparent to
+ touches, so the native block underneath still hears the swipes on its own content. */}
+ {overlay != null ? (
+
+ {overlay}
+
+ ) : null}
+
+ )
+}
+
+const styles = StyleSheet.create({
+ block: {
+ width: '100%',
+ overflow: 'hidden',
+ },
+})
+
+export default MindboxEmbeddedBlock
diff --git a/src/MindboxEmbeddedBlockNativeComponent.ts b/src/MindboxEmbeddedBlockNativeComponent.ts
new file mode 100644
index 0000000..31134dc
--- /dev/null
+++ b/src/MindboxEmbeddedBlockNativeComponent.ts
@@ -0,0 +1,78 @@
+import type { ViewProps } from 'react-native'
+import type { DirectEventHandler, Double, WithDefault } from 'react-native/Libraries/Types/CodegenTypes'
+import codegenNativeComponent from 'react-native/Libraries/Utilities/codegenNativeComponent'
+
+/**
+ * What the embedded block needs across the native boundary.
+ *
+ * The block is a view, not a call, so nothing here lands on the Turbo module: what crosses is one
+ * native component, the props the container reads, and the two signals the native block sends up —
+ * how it occupies its place and how its load ended.
+ *
+ * The wire words (`placeholder`, `content`, `error`, `collapsed`) are a contract with both native
+ * sides and are spelled out there, not derived from enum case names.
+ */
+type AppearanceChangeEvent = Readonly<{
+ /** One of `placeholder`, `content`, `error`, `collapsed`. A word this version does not know is ignored. */
+ appearance: string
+}>
+
+export interface NativeProps extends ViewProps {
+ /** The name of the place from the admin panel — what the native block resolves its content by. */
+ placeSystemName?: string
+
+ /**
+ * The height the block occupies, in points. Read only by the iOS side, and only to keep the
+ * container's own log honest: the frame itself comes from the style, as it does for every RN view.
+ */
+ blockHeight?: Double
+
+ /**
+ * How long the container waits for the answer about what to show, in whole milliseconds.
+ *
+ * Milliseconds because that is the one spelling both native sides can take without losing anything:
+ * Android counts its budget in them, iOS in seconds. Zero stands for "the host said nothing" — an
+ * absent prop crosses the boundary as the default, and the default has to mean something — and the
+ * container falls back to its own 30 seconds. The value is handed over once, when the block is
+ * built; the container cannot re-budget a running wait, so a change on a live block is not applied.
+ */
+ timeoutMs?: Double
+
+ /**
+ * Whether the host draws a loading screen of its own.
+ *
+ * Not the screen itself: the container is told only that the place is taken, and answers by
+ * holding back its own shimmer. What is drawn there is an RN overlay above the native view.
+ */
+ hasPlaceholder?: WithDefault
+
+ /**
+ * Whether the host draws a failure of its own — the same arrangement as [hasPlaceholder], with one
+ * difference: this is also what opts the block into showing a failure at all. Without it a failed
+ * block collapses.
+ */
+ hasErrorView?: WithDefault
+
+ /**
+ * Whether the host still shows the block.
+ *
+ * The container watches its window, and in RN that is not enough: a screen left behind in a native
+ * stack keeps its views in the window, so the block would spend its whole waiting budget on a
+ * screen nobody is looking at.
+ */
+ hostVisible?: WithDefault
+
+ /** Where the block stands now — a state that repeats, not an event. */
+ onAppearanceChange?: DirectEventHandler
+
+ /** The content is shown. */
+ onBlockLoad?: DirectEventHandler
+
+ /**
+ * The place ended up without content: the load failed or timed out, or there is nothing behind the
+ * name. Carries no payload yet — the reason lands here once the SDK tells the outcomes apart.
+ */
+ onBlockFail?: DirectEventHandler
+}
+
+export default codegenNativeComponent('MindboxEmbeddedBlockView')
diff --git a/src/NativeMindboxSdk.ts b/src/NativeMindboxSdk.ts
new file mode 100644
index 0000000..3d784b3
--- /dev/null
+++ b/src/NativeMindboxSdk.ts
@@ -0,0 +1,53 @@
+import type { TurboModule } from 'react-native'
+import { TurboModuleRegistry } from 'react-native'
+
+type EventSubscriptionLike = {
+ remove(): void
+}
+
+type EventEmitter = (handler: (event: T) => void | Promise) => EventSubscriptionLike
+
+export interface Spec extends TurboModule {
+ initialize(payloadString: string): Promise
+ registerCallbacks(callbacks: Array): void
+ getDeviceUUID(): Promise
+ getTokens(): Promise
+ executeAsyncOperation(operationSystemName: string, operationBody: string): Promise
+ executeSyncOperation(operationSystemName: string, operationBody: string): Promise
+ onPushClickedIsRegistered(isRegistered: boolean): void
+ setLogLevel(level: number): void
+ getSdkVersion(): Promise
+ pushDelivered(uniqKey: string): void
+ refreshNotificationPermissionStatus(): void
+ writeNativeLog(message: string, logLevel: number): void
+
+ readonly onPushNotificationClicked: EventEmitter<{ pushUrl: string; pushPayload: string }>
+ readonly onInAppClick: EventEmitter<{ id: string; redirectUrl: string; payload: string }>
+ readonly onInAppDismiss: EventEmitter<{ id: string }>
+}
+
+/**
+ * Lazy resolve: `getEnforcing` at import time can run before the native runtime has
+ * registered Turbo modules (`[runtime not ready]` / invariant in bridgeless).
+ */
+let mindboxTurboImpl: Spec | null = null
+
+function resolveMindboxTurbo(): Spec {
+ if (mindboxTurboImpl == null) {
+ mindboxTurboImpl = TurboModuleRegistry.getEnforcing('MindboxSdk')
+ }
+ return mindboxTurboImpl
+}
+
+const mindboxTurboModule: Spec = new Proxy({} as Spec, {
+ get(_target, prop: string | symbol) {
+ const m: Spec = resolveMindboxTurbo()
+ const value: unknown = Reflect.get(m as object, prop)
+ if (typeof value === 'function') {
+ return (value as (...args: unknown[]) => unknown).bind(m)
+ }
+ return value
+ },
+})
+
+export default mindboxTurboModule
diff --git a/src/__tests__/MindboxEmbeddedBlock.test.tsx b/src/__tests__/MindboxEmbeddedBlock.test.tsx
new file mode 100644
index 0000000..f9b763c
--- /dev/null
+++ b/src/__tests__/MindboxEmbeddedBlock.test.tsx
@@ -0,0 +1,233 @@
+import React from 'react'
+import { StyleSheet, Text, View } from 'react-native'
+import { act, create } from 'react-test-renderer'
+import type { ReactTestRenderer } from 'react-test-renderer'
+
+import { MindboxEmbeddedBlock } from '../MindboxEmbeddedBlock'
+
+// The native component is the boundary under test: the suite checks what crosses it — the props the
+// container reads and the two signals it sends back — not what the container does with them. Those
+// rules live in the native SDKs and are covered by their own suites.
+jest.mock('../MindboxEmbeddedBlockNativeComponent', () => {
+ const ReactActual = require('react')
+ const { View: RNView } = require('react-native')
+ return {
+ __esModule: true,
+ default: (props: unknown) => ReactActual.createElement(RNView, { ...(props as object), testID: 'native-block' }),
+ }
+})
+
+// The `any` casts below keep the suite indifferent to which @types/react the renderer's typings
+// resolve to: the SDK pins React 18, while a host app may typecheck this tree against React 19 or a
+// nested duplicate copy — and element and component types from two copies never match each other.
+const render = (element: React.ReactElement): ReactTestRenderer => {
+ let renderer: ReactTestRenderer
+ act(() => {
+ renderer = create(element as any)
+ })
+ return renderer!
+}
+
+const update = (renderer: ReactTestRenderer, element: React.ReactElement) => {
+ act(() => {
+ renderer.update(element as any)
+ })
+}
+
+const asType = (component: unknown) => component as any
+
+const nativeProps = (renderer: ReactTestRenderer) => renderer.root.findByProps({ testID: 'native-block' }).props
+
+const reportAppearance = (renderer: ReactTestRenderer, appearance: string) => {
+ act(() => {
+ nativeProps(renderer).onAppearanceChange({ nativeEvent: { appearance } })
+ })
+}
+
+/** The outermost view is the frame that owns the height the host sees. */
+const frameHeight = (renderer: ReactTestRenderer) => {
+ const frame = renderer.root.findAllByType(asType(View))[0]
+ return StyleSheet.flatten(frame.props.style).height
+}
+
+describe('MindboxEmbeddedBlock', () => {
+ it('takes its height while loading and hands it back when the block collapses', () => {
+ const renderer = render()
+
+ expect(frameHeight(renderer)).toBe(104)
+
+ reportAppearance(renderer, 'collapsed')
+
+ expect(frameHeight(renderer)).toBe(0)
+ })
+
+ it('resizes a live block in place when the height changes', () => {
+ const renderer = render()
+
+ update(renderer, )
+
+ expect(frameHeight(renderer)).toBe(80)
+ expect(nativeProps(renderer).blockHeight).toBe(80)
+ })
+
+ it('warns once about a place system name with spaces around it', () => {
+ const warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined)
+ const renderer = render()
+
+ update(renderer, )
+
+ expect(warn).toHaveBeenCalledTimes(1)
+ expect(warn.mock.calls[0][0]).toContain('spaces around it')
+ warn.mockRestore()
+ })
+
+ it('warns once about a place system name that is not there at all', () => {
+ const warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined)
+ const renderer = render()
+
+ update(renderer, )
+
+ expect(warn).toHaveBeenCalledTimes(1)
+ expect(warn.mock.calls[0][0]).toContain('without a place system name')
+ // Handed to the native side as it is: a nameless place has to collapse and report, not hang.
+ expect(nativeProps(renderer).placeSystemName).toBe('')
+ warn.mockRestore()
+ })
+
+ it('calls a name of nothing but spaces a missing name, not a padded one', () => {
+ const warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined)
+ render()
+
+ expect(warn).toHaveBeenCalledTimes(1)
+ expect(warn.mock.calls[0][0]).toContain('without a place system name')
+ expect(warn.mock.calls[0][0]).not.toContain('spaces around it')
+ warn.mockRestore()
+ })
+
+ it('hands the failure of a nameless place to the host and gives the space back', () => {
+ const warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined)
+ const onFail = jest.fn()
+ const renderer = render()
+
+ reportAppearance(renderer, 'collapsed')
+ act(() => {
+ nativeProps(renderer).onBlockFail()
+ })
+
+ expect(onFail).toHaveBeenCalledTimes(1)
+ expect(frameHeight(renderer)).toBe(0)
+ warn.mockRestore()
+ })
+
+ // Zero, negative and not-a-number are one case to the block — no space is no space — and the suite
+ // says so for each of them, since each arrives from a different mistake: a height left unset, a
+ // height computed into the negative, and a height computed from something that was not there.
+ it.each([
+ ['zero', 0],
+ ['negative', -104],
+ ['not a number', Number.NaN],
+ ])('warns about a %s height that reserves no space and hands the layout zero', (_name, height) => {
+ const warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined)
+ const renderer = render()
+
+ expect(warn).toHaveBeenCalledTimes(1)
+ expect(warn.mock.calls[0][0]).toContain('reserves no space')
+ expect(frameHeight(renderer)).toBe(0)
+ // Never a negative number across the boundary: the native side is handed the space it can lay out.
+ expect(nativeProps(renderer).blockHeight).toBe(0)
+ warn.mockRestore()
+ })
+
+ it('tells the native block the place, the height and whether the place is taken', () => {
+ const renderer = render(wait} active={false} />)
+
+ expect(nativeProps(renderer)).toMatchObject({
+ placeSystemName: 'stories',
+ blockHeight: 104,
+ hasPlaceholder: true,
+ hasErrorView: false,
+ hostVisible: false,
+ })
+ })
+
+ it('sends zero for a timeout the host did not set', () => {
+ const renderer = render()
+
+ expect(nativeProps(renderer).timeoutMs).toBe(0)
+ })
+
+ it('sends the timeout it was created with, in milliseconds', () => {
+ const renderer = render()
+
+ expect(nativeProps(renderer).timeoutMs).toBe(5000)
+ })
+
+ it('keeps the timeout it was created with and warns once about a change', () => {
+ const warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined)
+ const renderer = render()
+
+ update(renderer, )
+ update(renderer, )
+
+ expect(nativeProps(renderer).timeoutMs).toBe(5000)
+ expect(warn).toHaveBeenCalledTimes(1)
+ warn.mockRestore()
+ })
+
+ it('draws the host placeholder over the loading block and the host error over the failed one', () => {
+ const renderer = render(loading} error={broken} />)
+
+ expect(renderer.root.findByType(asType(Text)).props.children).toBe('loading')
+
+ reportAppearance(renderer, 'content')
+
+ expect(renderer.root.findAllByType(asType(Text))).toHaveLength(0)
+
+ reportAppearance(renderer, 'error')
+
+ expect(renderer.root.findByType(asType(Text)).props.children).toBe('broken')
+ })
+
+ it('delivers each outcome once and a changed outcome again', () => {
+ const onLoad = jest.fn()
+ const onFail = jest.fn()
+ const renderer = render()
+
+ act(() => {
+ nativeProps(renderer).onBlockLoad()
+ nativeProps(renderer).onBlockLoad()
+ })
+
+ expect(onLoad).toHaveBeenCalledTimes(1)
+
+ act(() => {
+ nativeProps(renderer).onBlockFail()
+ })
+
+ expect(onFail).toHaveBeenCalledTimes(1)
+ })
+
+ it('ignores an appearance it does not know, keeping the last known one', () => {
+ const renderer = render()
+
+ reportAppearance(renderer, 'collapsed')
+ reportAppearance(renderer, 'sideways')
+
+ expect(frameHeight(renderer)).toBe(0)
+ })
+
+ it('builds a different place as a different block, with nothing remembered', () => {
+ const onLoad = jest.fn()
+ const renderer = render()
+
+ act(() => {
+ nativeProps(renderer).onBlockLoad()
+ })
+ update(renderer, )
+ act(() => {
+ nativeProps(renderer).onBlockLoad()
+ })
+
+ expect(onLoad).toHaveBeenCalledTimes(2)
+ })
+})
diff --git a/src/__tests__/index.test.ts b/src/__tests__/index.test.ts
index b8b0a1e..daf45ae 100644
--- a/src/__tests__/index.test.ts
+++ b/src/__tests__/index.test.ts
@@ -31,42 +31,10 @@ jest.mock('react-native', () => {
resolve('UUID')
})
),
- getAPNSToken: jest.fn(
- () =>
- new Promise((resolve) => {
- resolve('APNS')
- })
- ),
- getFMSToken: jest.fn(
- () =>
- new Promise((resolve) => {
- resolve('FMS')
- })
- ),
getTokens: jest.fn(
() =>
new Promise((resolve) => {
- resolve('Tokens')
- })
- ),
- updateAPNSToken: jest.fn(
- (payloadString: string) =>
- new Promise((resolve, reject) => {
- if (payloadString && typeof payloadString === 'string') {
- resolve(true)
- } else {
- reject(new Error('Error'))
- }
- })
- ),
- updateFMSToken: jest.fn(
- (payloadString: string) =>
- new Promise((resolve, reject) => {
- if (payloadString && typeof payloadString === 'string') {
- resolve(true)
- } else {
- reject(new Error('Error'))
- }
+ resolve(JSON.stringify({ APNS: 'APNS', FCM: 'FMS' }))
})
),
executeAsyncOperation: jest.fn(
@@ -146,6 +114,20 @@ jest.mock('react-native', () => {
resolve(true)
})
),
+ getSdkVersion: jest.fn(() => Promise.resolve('1.0.0')),
+ refreshNotificationPermissionStatus: jest.fn(() => Promise.resolve()),
+ pushDelivered: jest.fn(),
+ registerCallbacks: jest.fn(),
+ setLogLevel: jest.fn(),
+ onPushClickedIsRegistered: jest.fn(),
+ onInAppClick: jest.fn(() => ({ remove: jest.fn() })),
+ onInAppDismiss: jest.fn(() => ({ remove: jest.fn() })),
+ onPushNotificationClicked: jest.fn(() => ({ remove: jest.fn() })),
+ }
+
+ actualReactNative.TurboModuleRegistry = {
+ ...(actualReactNative.TurboModuleRegistry || {}),
+ getEnforcing: jest.fn(() => actualReactNative.NativeModules.MindboxSdk),
}
actualReactNative.NativeModules.MindboxJsDelivery = {
@@ -241,76 +223,6 @@ describe('Testing Mindbox RN SDK', () => {
})
})
- describe('Testing getAPNSToken method', () => {
- it('resolves successfully with string payload', async () => {
- expect.assertions(1)
-
- await expect(MindboxSdk.getAPNSToken()).resolves.toEqual('APNS')
- })
- })
-
- describe('Testing getFMSToken method', () => {
- it('resolves successfully with string payload', async () => {
- expect.assertions(1)
-
- await expect(MindboxSdk.getFMSToken()).resolves.toEqual('FMS')
- })
- })
-
- describe('Testing updateAPNSToken method', () => {
- it('throws error when no paylaod passed', async () => {
- expect.assertions(1)
-
- await expect(MindboxSdk.updateAPNSToken()).rejects.toThrow('Error')
- })
-
- it('throws error when non string payload passed', async () => {
- expect.assertions(1)
-
- const wrongPaylaod = {
- one: 'one',
- two: 'two',
- }
-
- await expect(MindboxSdk.updateAPNSToken(wrongPaylaod)).rejects.toThrow('Error')
- })
-
- it('resolves successfully with string payload passed', async () => {
- expect.assertions(1)
-
- const payloadString = 'NewFMSToken'
-
- await expect(MindboxSdk.updateAPNSToken(payloadString)).resolves.toBeTruthy()
- })
- })
-
- describe('Testing updateFMSToken method', () => {
- it('throws error when no paylaod passed', async () => {
- expect.assertions(1)
-
- await expect(MindboxSdk.updateFMSToken()).rejects.toThrow('Error')
- })
-
- it('throws error when non string payload passed', async () => {
- expect.assertions(1)
-
- const wrongPaylaod = {
- one: 'one',
- two: 'two',
- }
-
- await expect(MindboxSdk.updateFMSToken(wrongPaylaod)).rejects.toThrow('Error')
- })
-
- it('resolves successfully with string payload passed', async () => {
- expect.assertions(1)
-
- const payloadString = 'NewFMSToken'
-
- await expect(MindboxSdk.updateFMSToken(payloadString)).resolves.toBeTruthy()
- })
- })
-
describe('Testing executeAsyncOperation method', () => {
it('throws error when no payload passed', async () => {
expect.assertions(2)
@@ -466,58 +378,113 @@ describe('Testing Mindbox RN SDK', () => {
expect(MindboxSdk.initialized).toBeTruthy()
})
- it('getDeviceUUID method works correctly', async () => {
+ it('initialize passes operationsDomain to native when provided', async () => {
+ const {
+ NativeModules: { MindboxSdk: MindboxSdkNative },
+ } = require('react-native')
const MindboxSdk = require('../index').default
expect.assertions(1)
- MindboxSdk.getDeviceUUID((uuid: string) => {
- expect(uuid).toEqual('UUID')
+ await MindboxSdk.initialize({
+ ...initializationData,
+ operationsDomain: 'anonymizer.example.com',
})
- await MindboxSdk.initialize(initializationData)
+ const calledWith = (MindboxSdkNative.initialize as jest.Mock).mock.calls.slice(-1)[0][0]
+ expect(JSON.parse(calledWith)).toMatchObject({ operationsDomain: 'anonymizer.example.com' })
})
- it('getToken method works correctly', async () => {
+ it('initialize does not include operationsDomain in payload when not provided', async () => {
+ const {
+ NativeModules: { MindboxSdk: MindboxSdkNative },
+ } = require('react-native')
const MindboxSdk = require('../index').default
+
+ expect.assertions(1)
+
await MindboxSdk.initialize(initializationData)
- expect.assertions(2)
+ const calledWith = (MindboxSdkNative.initialize as jest.Mock).mock.calls.slice(-1)[0][0]
+ expect(JSON.parse(calledWith)).not.toHaveProperty('operationsDomain')
+ })
+
+ it('initialize does not include operationsDomain in payload when passed as empty string', async () => {
+ const {
+ NativeModules: { MindboxSdk: MindboxSdkNative },
+ } = require('react-native')
+ const MindboxSdk = require('../index').default
- MindboxSdk.getToken((token: string) => {
- expect(token).toEqual('APNS')
+ expect.assertions(1)
+
+ await MindboxSdk.initialize({
+ ...initializationData,
+ operationsDomain: '',
})
- Platform.OS = 'android'
+ const calledWith = (MindboxSdkNative.initialize as jest.Mock).mock.calls.slice(-1)[0][0]
+ expect(JSON.parse(calledWith)).not.toHaveProperty('operationsDomain')
+ })
+
+ it('initialize passes shouldIncludeVersionCode to native when provided', async () => {
+ const {
+ NativeModules: { MindboxSdk: MindboxSdkNative },
+ } = require('react-native')
+ const MindboxSdk = require('../index').default
+
+ expect.assertions(1)
- MindboxSdk.getToken((token: string) => {
- expect(token).toEqual('FMS')
+ await MindboxSdk.initialize({
+ ...initializationData,
+ shouldIncludeVersionCode: false,
})
+
+ const calledWith = (MindboxSdkNative.initialize as jest.Mock).mock.calls.slice(-1)[0][0]
+ expect(JSON.parse(calledWith)).toMatchObject({ shouldIncludeVersionCode: false })
})
- it('getTokens method works correctly', async () => {
+ it('initialize does not include shouldIncludeVersionCode in payload when not provided', async () => {
+ const {
+ NativeModules: { MindboxSdk: MindboxSdkNative },
+ } = require('react-native')
const MindboxSdk = require('../index').default
+
+ expect.assertions(1)
+
await MindboxSdk.initialize(initializationData)
- expect.assertions(2)
+ const calledWith = (MindboxSdkNative.initialize as jest.Mock).mock.calls.slice(-1)[0][0]
+ expect(JSON.parse(calledWith)).not.toHaveProperty('shouldIncludeVersionCode')
+ })
- MindboxSdk.getToken((token: string) => {
- expect(token).toEqual('Tokens')
- })
+ it('getDeviceUUID method works correctly', async () => {
+ const MindboxSdk = require('../index').default
- Platform.OS = 'android'
+ expect.assertions(1)
- MindboxSdk.getToken((token: string) => {
- expect(token).toEqual('Tokens')
+ MindboxSdk.getDeviceUUID((uuid: string) => {
+ expect(uuid).toEqual('UUID')
})
+
+ await MindboxSdk.initialize(initializationData)
})
- it('updateToken method resolves successfully', async () => {
+ it('getTokens method works correctly', async () => {
const MindboxSdk = require('../index').default
+ await MindboxSdk.initialize(initializationData)
- expect.assertions(1)
+ expect.assertions(2)
+
+ MindboxSdk.getTokens((token: string) => {
+ expect(token).toEqual(JSON.stringify({ APNS: 'APNS', FCM: 'FMS' }))
+ })
- await expect(MindboxSdk.updateToken('newToken')).resolves.toBeUndefined()
+ Platform.OS = 'android'
+
+ MindboxSdk.getTokens((token: string) => {
+ expect(token).toEqual(JSON.stringify({ APNS: 'APNS', FCM: 'FMS' }))
+ })
+ Platform.OS = 'ios'
})
it('onPushClickReceived method works correctly', () => {
diff --git a/src/index.tsx b/src/index.tsx
index 28264da..bf95575 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -1,71 +1,78 @@
-import { EmitterSubscription, NativeEventEmitter, NativeModules, Platform } from 'react-native'
-
import type { InitializationData, ExecuteSyncOperationPayload, ExecuteAsyncOperationPayload } from './types'
import type { InAppCallback } from './InAppCallback'
+import MindboxSdkNative from './NativeMindboxSdk'
import { LogLevel } from './LogLevel'
-const { MindboxSdk: MindboxSdkNative, MindboxJsDelivery } = NativeModules
+type RemovableSubscription = {
+ remove(): void
+}
+
+type InAppClickEventPayload = {
+ id: string
+ redirectUrl: string
+ payload: string
+}
+
+type InAppDismissEventPayload = {
+ id: string
+}
+
+type PushNotificationClickedPayload = {
+ pushUrl: string
+ pushPayload: string
+}
class MindboxSdkClass {
private _initialized: boolean
private _initializing: boolean
private _callbacks: Array<() => void>
- private _mindboxJsDeliveryEvents: NativeEventEmitter
- private _emitterSubscribtion?: EmitterSubscription
+ private _pushSubscription?: RemovableSubscription
+ private _inAppClickSubscription?: RemovableSubscription
+ private _inAppDismissSubscription?: RemovableSubscription
private readonly _prefix: string = '[RN]'
constructor() {
this._initialized = false
this._initializing = false
this._callbacks = []
- this._mindboxJsDeliveryEvents = new NativeEventEmitter(MindboxJsDelivery)
}
- /**
- * @name initialized
- * @type {boolean}
- * @description Is MindboxSdk already initialized.
- */
get initialized() {
return this._initialized
}
- /**
- * @name subscribedForPushClickedEvent
- * @type {boolean}
- * @description Is there any subscription on push notification tapped.
- */
get subscribedForPushClickedEvent() {
- return !!this._emitterSubscribtion
+ return !!this._pushSubscription
}
public registerInAppCallbacks(callbacks: Array) {
+ this._inAppClickSubscription?.remove()
+ this._inAppDismissSubscription?.remove()
+ this._inAppClickSubscription = undefined
+ this._inAppDismissSubscription = undefined
+
let customCallback: InAppCallback | undefined
const callbackNames = callbacks.map((callback) => {
const name = callback.getName()
switch (name) {
- case 'urlInAppCallback': {
+ case 'urlInAppCallback':
+ case 'copyPayloadInAppCallback':
+ case 'emptyInAppCallback':
break
- }
- case 'copyPayloadInAppCallback': {
- break
- }
- case 'emptyInAppCallback': {
- break
- }
- default: {
+ default:
customCallback = callback
- }
}
return name
})
- this._mindboxJsDeliveryEvents.addListener('Click', (event) => {
+
+ this._inAppClickSubscription = MindboxSdkNative.onInAppClick((event: InAppClickEventPayload) => {
customCallback?.onInAppClick(event.id, event.redirectUrl, event.payload)
})
- this._mindboxJsDeliveryEvents.addListener('Dismiss', (event) => {
+ this._inAppDismissSubscription = MindboxSdkNative.onInAppDismiss((event: InAppDismissEventPayload) => {
customCallback?.onInAppDismissed(event.id)
})
+
MindboxSdkNative.registerCallbacks(callbackNames)
}
@@ -76,12 +83,14 @@ class MindboxSdkClass {
*
* @example
* await MindboxSdk.initialize({
- * domain: 'api.mindbox.ru',
+ * domain: 'your-domain.example.com',
* endpointId: 'your-endpoint-id-here',
* subscribeCustomerIfCreated: true,
* shouldCreateCustomer: true,
* previousInstallId: '',
* previousUuid: '',
+ * operationsDomain: 'anonymizer.example.com',
+ * shouldIncludeVersionCode: false, // Android only (ignored on iOS). Default value is true
* });
*/
public async initialize(initializationData: InitializationData) {
@@ -97,7 +106,7 @@ class MindboxSdkClass {
throw new Error('Wrong initialization data!')
}
- const { domain, endpointId, subscribeCustomerIfCreated, shouldCreateCustomer, previousInstallId, previousUuid } = initializationData
+ const { domain, endpointId, subscribeCustomerIfCreated, shouldCreateCustomer, previousInstallId, previousUuid, operationsDomain, shouldIncludeVersionCode } = initializationData
if (!domain || !endpointId) {
this._initializing = false
@@ -125,6 +134,14 @@ class MindboxSdkClass {
payload.previousUuid = previousUuid
}
+ if (typeof operationsDomain !== 'undefined' && operationsDomain.length > 0) {
+ payload.operationsDomain = operationsDomain
+ }
+
+ if (typeof shouldIncludeVersionCode !== 'undefined') {
+ payload.shouldIncludeVersionCode = shouldIncludeVersionCode
+ }
+
try {
const payloadString = JSON.stringify(payload)
this._initialized = await MindboxSdkNative.initialize(payloadString)
@@ -166,45 +183,6 @@ class MindboxSdkClass {
}
}
- /**
- * @name getToken
- * @description Requires a callback that will return FMS (Android) / APNS (iOS) token.
- * @param {function(token: String): void} callback Callback will return FMS (Android) / APNS (iOS) token
- * @deprecated since version 2.12.0. Use getTokens
- * @example
- * MindboxSdk.getToken((token: string) => { ... });
- */
- public getToken(callback: (token: string) => void) {
- if (!callback || typeof callback !== 'function') {
- throw new Error('callback is required!')
- }
-
- const callbackHandler = () => {
- let promise = null
-
- switch (Platform.OS) {
- case 'ios':
- promise = MindboxSdkNative.getAPNSToken()
- break
-
- case 'android':
- promise = MindboxSdkNative.getFMSToken()
- break
-
- default:
- promise = MindboxSdkNative.getAPNSToken()
- break
- }
-
- promise.then((token: string) => callback(token))
- }
-
- if (this._initialized) {
- callbackHandler()
- } else {
- this._callbacks.push(callbackHandler)
- }
- }
/**
* @name getTokens
* @description Requires a callback that will return FMS (Android) / APNS (iOS) token .
@@ -219,9 +197,7 @@ class MindboxSdkClass {
}
const callbackHandler = () => {
- let promise = null
- promise = MindboxSdkNative.getTokens()
- promise.then((token: string) => callback(token))
+ MindboxSdkNative.getTokens().then((token: string) => callback(token))
}
if (this._initialized) {
@@ -231,46 +207,6 @@ class MindboxSdkClass {
}
}
- /**
- * @name updateToken
- * @description Updates your FMS/APNS token.
- * @param {String} token Your new fms/apns token
- * @deprecated since version 2.12.0. Use native methods
- * @example
- * await MindboxSdk.updateToken('your-fms/apns-token');
- */
- public async updateToken(token: string) {
- if (!token || typeof token !== 'string') {
- throw new Error('token is required!')
- }
-
- switch (Platform.OS) {
- case 'ios':
- try {
- await MindboxSdkNative.updateAPNSToken(token)
- } catch (error) {
- throw error
- }
- break
-
- case 'android':
- try {
- await MindboxSdkNative.updateFMSToken(token)
- } catch (error) {
- throw error
- }
- break
-
- default:
- try {
- await MindboxSdkNative.updateAPNSToken(token)
- } catch (error) {
- throw error
- }
- break
- }
- }
-
/**
* @name onPushClickReceived
* @description Listens if push notification or push notification button were pressed.
@@ -286,15 +222,14 @@ class MindboxSdkClass {
}
this.removeOnPushClickReceived()
- this.writeNativeLog(`Set push click listener`, LogLevel.INFO)
- this._emitterSubscribtion = this._mindboxJsDeliveryEvents.addListener('pushNotificationClicked', (dataString: string) => {
- const data = JSON.parse(dataString)
- callback(data.pushUrl || null, data.pushPayload || null)
+ this.writeNativeLog('Set push click listener', LogLevel.INFO)
+
+ this._pushSubscription = MindboxSdkNative.onPushNotificationClicked((event: PushNotificationClickedPayload) => {
+ callback(event.pushUrl || null, event.pushPayload || null)
})
- if (Platform.OS === 'android') {
- this.writeNativeLog('Register push click listener for android', LogLevel.INFO)
- MindboxSdkNative.onPushClickedIsRegistered(true)
- }
+
+ this.writeNativeLog('Register push click listener', LogLevel.INFO)
+ MindboxSdkNative.onPushClickedIsRegistered(true)
}
/**
@@ -305,12 +240,10 @@ class MindboxSdkClass {
* MindboxSdk.removeOnPushClickReceived();
*/
public removeOnPushClickReceived() {
- if (this._emitterSubscribtion) {
- this._emitterSubscribtion.remove()
- this._emitterSubscribtion = undefined
- if (Platform.OS === 'android') {
- MindboxSdkNative.onPushClickedIsRegistered(false)
- }
+ if (this._pushSubscription) {
+ this._pushSubscription.remove()
+ this._pushSubscription = undefined
+ MindboxSdkNative.onPushClickedIsRegistered(false)
}
}
@@ -433,19 +366,6 @@ class MindboxSdkClass {
return MindboxSdkNative.pushDelivered(uniqKey)
}
- /**
- * This method is kept for backward compatibility. The `granted` argument is ignored.
- * The SDK reads the current system authorization status and, if it differs
- * from the last known value, sends an update to the backend.
- *
- * @param granted current permission status
- * @deprecated Use `refreshNotificationPermissionStatus()` instead.
- */
- public updateNotificationPermissionStatus(granted: Boolean) {
- console.warn(`updateNotificationPermissionStatus(granted=${String(granted)}) is deprecated. Use refreshNotificationPermissionStatus instead.`)
- return MindboxSdkNative.refreshNotificationPermissionStatus()
- }
-
/**
* Checks the current system authorization status for push notifications
* and reports any changes to Mindbox.
@@ -483,3 +403,7 @@ export default MindboxSdk
export { LogLevel } from './LogLevel'
export { InAppCallback, CopyPayloadInAppCallback, EmptyInAppCallback, UrlInAppCallback } from './InAppCallback'
+
+export { MindboxEmbeddedBlock } from './MindboxEmbeddedBlock'
+
+export type { MindboxEmbeddedBlockProps, MindboxEmbeddedBlockFailure } from './MindboxEmbeddedBlock'
diff --git a/src/types/InitializationData.ts b/src/types/InitializationData.ts
index 45de9cb..46789ac 100644
--- a/src/types/InitializationData.ts
+++ b/src/types/InitializationData.ts
@@ -5,4 +5,11 @@ export type InitializationData = {
shouldCreateCustomer?: boolean
previousInstallId?: string
previousUuid?: string
+ operationsDomain?: string
+ /**
+ * Android only, ignored on iOS. Specifies whether the app versionCode is included
+ * in the app version reported to Mindbox. When false, only versionName is reported.
+ * Default value is true.
+ */
+ shouldIncludeVersionCode?: boolean
}
diff --git a/tsconfig.json b/tsconfig.json
index a0df1cd..c8b3ec0 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -8,7 +8,6 @@
"allowUnreachableCode": false,
"allowUnusedLabels": false,
"esModuleInterop": true,
- "importsNotUsedAsValues": "error",
"forceConsistentCasingInFileNames": true,
"jsx": "react",
"lib": ["esnext"],