Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions sample/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,34 @@ xcodebuild -workspace MParticleSample.xcworkspace \

Pull requests run these tests in CI (see `.github/workflows/pull-request.yml`).

## Deferred-init edge case (Android)

The sample includes a small, opt-in example that reproduces a late-initialisation race on
Android: starting mParticle from a native module at first-frame paint (a partner pattern used
to cut startup cost) instead of in `MainApplication.onCreate()`. Because the Rokt SDK caches
the current `Activity` only on `onActivityResumed` (≤ v5), deferring init past the host
Activity's resume leaves overlay/bottom-sheet placements unable to display until the next
resume. iOS is unaffected. Fixed upstream in the Rokt Android SDK (`sdk-android-source`
[#1062](https://github.com/ROKT/sdk-android-source/pull/1062),
[#1063](https://github.com/ROKT/sdk-android-source/pull/1063)).

It is **disabled by default**. To reproduce:

1. Set `DEFERRED_INIT_EXAMPLE = true` in
`android/app/src/main/java/com/mparticlesample/DeferredInitModule.kt`.
2. Run the app and watch `adb logcat -s DeferredInitRepro`.

The `EAGER` tracker (registered at process start) captures `MainActivity`; the `DEFERRED`
tracker (registered when init runs at first frame) stays `null` until you background and
reopen the app — demonstrating the race. See `DeferredInitModule.kt` for the full write-up.

> **Note:** When the flag is enabled, mParticle is not started until first-frame paint, so any
> mParticle JS calls made earlier (e.g. `Identity.login` in the component constructor,
> `getSession` in `componentDidMount`) run before the SDK is started and are no-ops until then.
> This is itself an inherent hazard of deferred initialisation and is expected in this example;
> gate such calls behind init completion in a real deferred-init integration. With the flag off
> (default) mParticle starts eagerly in `onCreate()`, so these calls behave normally.

## Additional Resources

- [mParticle Documentation](https://docs.mparticle.com/)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package com.mparticlesample

import android.app.Activity
import android.app.Application
import android.os.Bundle
import android.util.Log
import java.lang.ref.WeakReference

/**
* Mirrors the Rokt Android SDK's `ActivityLifeCycleObserver` caching strategy:
* the "current activity" is captured ONLY in onActivityResumed. This is the
* mechanism RoktModalActivity (overlay / bottom-sheet placements) depends on.
*
* We register two trackers in the repro:
* - EAGER : registered in Application.onCreate (before MainActivity exists)
* - DEFERRED: registered when MParticle.start() is called at first-frame paint
*
* If the deferred tracker is registered AFTER the host Activity has already
* resumed, it never sees onActivityResumed and currentActivity stays null --
* exactly the failure ROKT/sdk-android-source#1062 / #1063 fix.
*/
class ActivityTracker(private val label: String) : Application.ActivityLifecycleCallbacks {

@Volatile
private var currentActivityRef: WeakReference<Activity>? = null

val currentActivity: Activity?
get() = currentActivityRef?.get()

companion object {
const val TAG = "DeferredInitRepro"
}

override fun onActivityResumed(activity: Activity) {
currentActivityRef = WeakReference(activity)
Log.i(TAG, "[$label] onActivityResumed -> captured ${activity.localClassName}")
}

override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
Log.i(TAG, "[$label] onActivityCreated ${activity.localClassName}")
}

override fun onActivityStarted(activity: Activity) {}
override fun onActivityPaused(activity: Activity) {}
override fun onActivityStopped(activity: Activity) {}
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {}
override fun onActivityDestroyed(activity: Activity) {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
package com.mparticlesample

import android.app.Application
import android.os.Handler
import android.os.Looper
import android.util.Log
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.mparticle.MParticle
import com.mparticle.MParticleOptions
import com.mparticle.identity.IdentityApiRequest

/**
* Edge-case example: "deferred initialisation with Turbo Modules".
*
* Instead of calling MParticle.start() in MainApplication.onCreate() (the standard,
* supported integration), JS calls startMParticle() from this native module after the
* first frame is painted. This reproduces a partner pattern used to shave init cost off
* app startup -- and exposes an Android-specific race that does NOT occur on iOS:
*
* The Rokt SDK caches its "current Activity" only in onActivityResumed, via an observer
* registered during Rokt.init() (which mParticle calls from RoktKit.onKitCreate()). When
* init runs AFTER the host Activity has already resumed -- exactly what deferred init does
* -- that resume is missed, so overlay/bottom-sheet placements (RoktModalActivity) have no
* Activity to launch from until the next resume (the "press home and reopen" workaround).
* iOS is immune because it resolves the presenter lazily at execute time.
*
* Fixed upstream in the Rokt Android SDK by observing the lifecycle from process start:
* ROKT/sdk-android-source#1062 and #1063 (v5 backport #1082). This example is kept around
* as an easy way to re-trigger the scenario and confirm the fix / catch regressions.
*
* To enable: flip DEFERRED_INIT_EXAMPLE to true and watch `adb logcat -s DeferredInitRepro`
* -- the DEFERRED tracker's currentActivity stays null while the EAGER one captures it.
*
* (Registered through a legacy ReactPackage; under the New Architecture it is invoked via the
* TurboModule interop layer. The JS call timing -- after first-frame paint -- is identical to
* a pure TurboModule, which is what the race depends on.)
*/
class DeferredInitModule(private val reactContext: ReactApplicationContext) :
ReactContextBaseJavaModule(reactContext) {

companion object {
const val TAG = "DeferredInitRepro"

/**
* Master switch for the deferred-init edge case. Keep this `false` so the sample uses the
* standard eager init in MainApplication.onCreate(); set it to `true` to reproduce the
* late-init Activity-capture race described above.
*/
const val DEFERRED_INIT_EXAMPLE = false

// Registered in Application.onCreate -- the eager path, for contrast.
var eagerTracker: ActivityTracker? = null
}

// Registered at deferred-start time, mirroring Rokt.init()'s late registration.
private val deferredTracker = ActivityTracker("DEFERRED")

override fun getName(): String = "DeferredInit"

@ReactMethod
fun startMParticle(promise: Promise) {
if (!DEFERRED_INIT_EXAMPLE) {
// Standard mode: mParticle was already started in Application.onCreate(); nothing to do.
promise.resolve("eager-init (deferred example disabled)")
return
}

// Idempotent: JS may invoke this more than once (Fast Refresh, remounts). Once mParticle
// has started, skip re-registering the lifecycle observer and re-starting the SDK.
if (MParticle.getInstance() != null) {
promise.resolve("already started")
return
}

Log.i(TAG, "startMParticle() called from JS (post first-frame). MParticle.getInstance()=${MParticle.getInstance()}")

val app = reactContext.applicationContext as Application

// Mirror Rokt SDK: register the activity observer at init time, NOT at process start.
app.registerActivityLifecycleCallbacks(deferredTracker)
Log.i(TAG, "[DEFERRED] tracker registered. currentActivity right now = ${deferredTracker.currentActivity}")

val identityRequest = IdentityApiRequest.withEmptyUser()
val options = MParticleOptions.builder(app)
.credentials("REPLACE_ME", "REPLACE_ME")
.logLevel(MParticle.LogLevel.VERBOSE)
.identify(identityRequest.build())
.build()

MParticle.start(options)
Log.i(TAG, "MParticle.start() invoked from native module. getInstance()=${MParticle.getInstance()}")

// Snapshot the captured activity 2s later: did the deferred observer ever
// see a resume? (Rokt's currentActivity cache works exactly this way.)
Handler(Looper.getMainLooper()).postDelayed({
Log.i(
TAG,
"SNAPSHOT after 2s -> EAGER.currentActivity=${eagerTracker?.currentActivity?.localClassName} | " +
"DEFERRED.currentActivity=${deferredTracker.currentActivity?.localClassName}"
)
}, 2000)

promise.resolve("started")
}

@ReactMethod
fun reportActivityState(promise: Promise) {
val msg = "EAGER=${eagerTracker?.currentActivity?.localClassName} | DEFERRED=${deferredTracker.currentActivity?.localClassName}"
Log.i(TAG, "reportActivityState -> $msg")
promise.resolve(msg)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.mparticlesample

import com.facebook.react.ReactPackage
import com.facebook.react.bridge.NativeModule
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.uimanager.ViewManager

class DeferredInitPackage : ReactPackage {
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> =
listOf(DeferredInitModule(reactContext))

override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> =
emptyList()
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,35 +6,46 @@ import com.facebook.react.ReactApplication
import com.facebook.react.ReactHost
import com.facebook.react.ReactNativeApplicationEntryPoint.loadReactNative
import com.facebook.react.defaults.DefaultReactHost.getDefaultReactHost
import com.mparticle.react.MParticlePackage
import com.mparticle.MParticle
import com.mparticle.MParticleOptions
import com.mparticle.identity.IdentityApiRequest
import com.mparticle.react.MParticlePackage

class MainApplication : Application(), ReactApplication {

override val reactHost: ReactHost by lazy {
getDefaultReactHost(
context = applicationContext,
packageList =
PackageList(this).packages.apply {
add(MParticlePackage())
},
)
}

override fun onCreate() {
super.onCreate()
loadReactNative(this)

val identityRequest = IdentityApiRequest.withEmptyUser()
override val reactHost: ReactHost by lazy {
getDefaultReactHost(
context = applicationContext,
packageList =
PackageList(this).packages.apply {
add(MParticlePackage())
add(DeferredInitPackage())
},
)
}

val options = MParticleOptions.builder(this)
.credentials("REPLACE_ME","REPLACE_ME")
.logLevel(MParticle.LogLevel.VERBOSE)
.identify(identityRequest.build())
.build()
override fun onCreate() {
super.onCreate()
loadReactNative(this)

MParticle.start(options)
}
if (DeferredInitModule.DEFERRED_INIT_EXAMPLE) {
// Deferred-init edge case (see DeferredInitModule for the full explanation).
// Register an eager activity tracker at process start -- before the first
// Activity is created -- so logcat can contrast it against the deferred path.
// MParticle.start() is intentionally NOT called here; DeferredInitModule
// starts it at first-frame paint instead (see index.js).
val eager = ActivityTracker("EAGER")
registerActivityLifecycleCallbacks(eager)
DeferredInitModule.eagerTracker = eager
} else {
// Standard, supported integration: start mParticle in Application.onCreate().
val identityRequest = IdentityApiRequest.withEmptyUser()
val options = MParticleOptions.builder(this)
.credentials("REPLACE_ME", "REPLACE_ME")
.logLevel(MParticle.LogLevel.VERBOSE)
.identify(identityRequest.build())
.build()
MParticle.start(options)
}
}
}
22 changes: 22 additions & 0 deletions sample/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
View,
TouchableOpacity,
KeyboardAvoidingView,
NativeModules,
} from 'react-native';
import MParticle from 'react-native-mparticle';

Expand Down Expand Up @@ -170,6 +171,27 @@ export default class MParticleSample extends Component {
}

componentDidMount() {
// Deferred-init edge-case example (Android only). The DeferredInit native module exists only
// on Android; iOS resolves the presenter lazily and is unaffected, so we skip it there to keep
// iOS quiet. When DeferredInitModule.DEFERRED_INIT_EXAMPLE is enabled, mParticle is started
// from the native module at the moment the first frame is painted, instead of in
// MainApplication.onCreate(). requestAnimationFrame fires after the first frame is committed --
// by which point MainActivity has already RESUMED, which is what triggers the Rokt overlay
// Activity-capture race. When the flag is off (default) this is a no-op on the native side.
// See DeferredInitModule.kt for the full explanation.
if (Platform.OS === 'android') {
requestAnimationFrame(() => {
const {DeferredInit} = NativeModules;
if (DeferredInit) {
DeferredInit.startMParticle()
.then(r => console.log('Deferred MParticle.start ->', r))
.catch(e => console.warn('Deferred start failed', e));
} else {
console.warn('DeferredInit native module not available');
}
});
}

Comment thread
cursor[bot] marked this conversation as resolved.
MParticle.getSession(session => this.setState({session}));

if (eventManagerEmitter) {
Expand Down
Loading