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 + +