Skip to content

Commit 99ece0b

Browse files
test(sample): add opt-in deferred-init edge-case example (Android)
Adds a self-contained, disabled-by-default example that reproduces the Android late-initialisation Activity-capture race: starting mParticle from a native module at first-frame paint instead of Application.onCreate(). The Rokt SDK (<= v5) caches the current Activity only on onActivityResumed (registered during Rokt.init() via RoktKit.onKitCreate()), so deferring init past the host Activity's resume leaves overlay placements unable to display until the next resume. iOS is unaffected. Fixed upstream in the Rokt Android SDK (ROKT/sdk-android-source #1062, #1063; v5 backport #1082). Default sample behaviour is unchanged (standard eager init). Flip DeferredInitModule.DEFERRED_INIT_EXAMPLE to true to reproduce; the EAGER vs DEFERRED ActivityTracker logs (adb logcat -s DeferredInitRepro) make the race observable. Kept around as an easy regression/verification fixture. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent a62b96e commit 99ece0b

6 files changed

Lines changed: 229 additions & 9 deletions

File tree

sample/README.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,27 @@ xcodebuild -workspace MParticleSample.xcworkspace \
159159

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

162+
## Deferred-init edge case (Android)
163+
164+
The sample includes a small, opt-in example that reproduces a late-initialisation race on
165+
Android: starting mParticle from a native module at first-frame paint (a partner pattern used
166+
to cut startup cost) instead of in `MainApplication.onCreate()`. Because the Rokt SDK caches
167+
the current `Activity` only on `onActivityResumed` (≤ v5), deferring init past the host
168+
Activity's resume leaves overlay/bottom-sheet placements unable to display until the next
169+
resume. iOS is unaffected. Fixed upstream in the Rokt Android SDK (`sdk-android-source`
170+
[#1062](https://github.com/ROKT/sdk-android-source/pull/1062),
171+
[#1063](https://github.com/ROKT/sdk-android-source/pull/1063)).
172+
173+
It is **disabled by default**. To reproduce:
174+
175+
1. Set `DEFERRED_INIT_EXAMPLE = true` in
176+
`android/app/src/main/java/com/mparticlesample/DeferredInitModule.kt`.
177+
2. Run the app and watch `adb logcat -s DeferredInitRepro`.
178+
179+
The `EAGER` tracker (registered at process start) captures `MainActivity`; the `DEFERRED`
180+
tracker (registered when init runs at first frame) stays `null` until you background and
181+
reopen the app — demonstrating the race. See `DeferredInitModule.kt` for the full write-up.
182+
162183
## Additional Resources
163184

164185
- [mParticle Documentation](https://docs.mparticle.com/)
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package com.mparticlesample
2+
3+
import android.app.Activity
4+
import android.app.Application
5+
import android.os.Bundle
6+
import android.util.Log
7+
import java.lang.ref.WeakReference
8+
9+
/**
10+
* Mirrors the Rokt Android SDK's `ActivityLifeCycleObserver` caching strategy:
11+
* the "current activity" is captured ONLY in onActivityResumed. This is the
12+
* mechanism RoktModalActivity (overlay / bottom-sheet placements) depends on.
13+
*
14+
* We register two trackers in the repro:
15+
* - EAGER : registered in Application.onCreate (before MainActivity exists)
16+
* - DEFERRED: registered when MParticle.start() is called at first-frame paint
17+
*
18+
* If the deferred tracker is registered AFTER the host Activity has already
19+
* resumed, it never sees onActivityResumed and currentActivity stays null --
20+
* exactly the failure ROKT/sdk-android-source#1062 / #1063 fix.
21+
*/
22+
class ActivityTracker(private val label: String) : Application.ActivityLifecycleCallbacks {
23+
24+
@Volatile
25+
private var currentActivityRef: WeakReference<Activity>? = null
26+
27+
val currentActivity: Activity?
28+
get() = currentActivityRef?.get()
29+
30+
companion object {
31+
const val TAG = "DeferredInitRepro"
32+
}
33+
34+
override fun onActivityResumed(activity: Activity) {
35+
currentActivityRef = WeakReference(activity)
36+
Log.i(TAG, "[$label] onActivityResumed -> captured ${activity.localClassName}")
37+
}
38+
39+
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
40+
Log.i(TAG, "[$label] onActivityCreated ${activity.localClassName}")
41+
}
42+
43+
override fun onActivityStarted(activity: Activity) {}
44+
override fun onActivityPaused(activity: Activity) {}
45+
override fun onActivityStopped(activity: Activity) {}
46+
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {}
47+
override fun onActivityDestroyed(activity: Activity) {}
48+
}
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
package com.mparticlesample
2+
3+
import android.app.Application
4+
import android.os.Handler
5+
import android.os.Looper
6+
import android.util.Log
7+
import com.facebook.react.bridge.Promise
8+
import com.facebook.react.bridge.ReactApplicationContext
9+
import com.facebook.react.bridge.ReactContextBaseJavaModule
10+
import com.facebook.react.bridge.ReactMethod
11+
import com.mparticle.MParticle
12+
import com.mparticle.MParticleOptions
13+
import com.mparticle.identity.IdentityApiRequest
14+
15+
/**
16+
* Edge-case example: "deferred initialisation with Turbo Modules".
17+
*
18+
* Instead of calling MParticle.start() in MainApplication.onCreate() (the standard,
19+
* supported integration), JS calls startMParticle() from this native module after the
20+
* first frame is painted. This reproduces a partner pattern used to shave init cost off
21+
* app startup -- and exposes an Android-specific race that does NOT occur on iOS:
22+
*
23+
* The Rokt SDK caches its "current Activity" only in onActivityResumed, via an observer
24+
* registered during Rokt.init() (which mParticle calls from RoktKit.onKitCreate()). When
25+
* init runs AFTER the host Activity has already resumed -- exactly what deferred init does
26+
* -- that resume is missed, so overlay/bottom-sheet placements (RoktModalActivity) have no
27+
* Activity to launch from until the next resume (the "press home and reopen" workaround).
28+
* iOS is immune because it resolves the presenter lazily at execute time.
29+
*
30+
* Fixed upstream in the Rokt Android SDK by observing the lifecycle from process start:
31+
* ROKT/sdk-android-source#1062 and #1063 (v5 backport #1082). This example is kept around
32+
* as an easy way to re-trigger the scenario and confirm the fix / catch regressions.
33+
*
34+
* To enable: flip DEFERRED_INIT_EXAMPLE to true and watch `adb logcat -s DeferredInitRepro`
35+
* -- the DEFERRED tracker's currentActivity stays null while the EAGER one captures it.
36+
*
37+
* (Registered through a legacy ReactPackage; under the New Architecture it is invoked via the
38+
* TurboModule interop layer. The JS call timing -- after first-frame paint -- is identical to
39+
* a pure TurboModule, which is what the race depends on.)
40+
*/
41+
class DeferredInitModule(private val reactContext: ReactApplicationContext) :
42+
ReactContextBaseJavaModule(reactContext) {
43+
44+
companion object {
45+
const val TAG = "DeferredInitRepro"
46+
47+
/**
48+
* Master switch for the deferred-init edge case. Keep this `false` so the sample uses the
49+
* standard eager init in MainApplication.onCreate(); set it to `true` to reproduce the
50+
* late-init Activity-capture race described above.
51+
*/
52+
const val DEFERRED_INIT_EXAMPLE = false
53+
54+
// Registered in Application.onCreate -- the eager path, for contrast.
55+
var eagerTracker: ActivityTracker? = null
56+
}
57+
58+
// Registered at deferred-start time, mirroring Rokt.init()'s late registration.
59+
private val deferredTracker = ActivityTracker("DEFERRED")
60+
61+
override fun getName(): String = "DeferredInit"
62+
63+
@ReactMethod
64+
fun startMParticle(promise: Promise) {
65+
if (!DEFERRED_INIT_EXAMPLE) {
66+
// Standard mode: mParticle was already started in Application.onCreate(); nothing to do.
67+
promise.resolve("eager-init (deferred example disabled)")
68+
return
69+
}
70+
71+
Log.i(TAG, "startMParticle() called from JS (post first-frame). MParticle.getInstance()=${MParticle.getInstance()}")
72+
73+
val app = reactContext.applicationContext as Application
74+
75+
// Mirror Rokt SDK: register the activity observer at init time, NOT at process start.
76+
app.registerActivityLifecycleCallbacks(deferredTracker)
77+
Log.i(TAG, "[DEFERRED] tracker registered. currentActivity right now = ${deferredTracker.currentActivity}")
78+
79+
val identityRequest = IdentityApiRequest.withEmptyUser()
80+
val options = MParticleOptions.builder(app)
81+
.credentials("REPLACE_ME", "REPLACE_ME")
82+
.logLevel(MParticle.LogLevel.VERBOSE)
83+
.identify(identityRequest.build())
84+
.build()
85+
86+
MParticle.start(options)
87+
Log.i(TAG, "MParticle.start() invoked from native module. getInstance()=${MParticle.getInstance()}")
88+
89+
// Snapshot the captured activity 2s later: did the deferred observer ever
90+
// see a resume? (Rokt's currentActivity cache works exactly this way.)
91+
Handler(Looper.getMainLooper()).postDelayed({
92+
Log.i(
93+
TAG,
94+
"SNAPSHOT after 2s -> EAGER.currentActivity=${eagerTracker?.currentActivity?.localClassName} | " +
95+
"DEFERRED.currentActivity=${deferredTracker.currentActivity?.localClassName}"
96+
)
97+
}, 2000)
98+
99+
promise.resolve("started")
100+
}
101+
102+
@ReactMethod
103+
fun reportActivityState(promise: Promise) {
104+
val msg = "EAGER=${eagerTracker?.currentActivity?.localClassName} | DEFERRED=${deferredTracker.currentActivity?.localClassName}"
105+
Log.i(TAG, "reportActivityState -> $msg")
106+
promise.resolve(msg)
107+
}
108+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package com.mparticlesample
2+
3+
import com.facebook.react.ReactPackage
4+
import com.facebook.react.bridge.NativeModule
5+
import com.facebook.react.bridge.ReactApplicationContext
6+
import com.facebook.react.uimanager.ViewManager
7+
8+
class DeferredInitPackage : ReactPackage {
9+
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> =
10+
listOf(DeferredInitModule(reactContext))
11+
12+
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> =
13+
emptyList()
14+
}

sample/android/app/src/main/java/com/mparticlesample/MainApplication.kt

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ class MainApplication : Application(), ReactApplication {
1919
packageList =
2020
PackageList(this).packages.apply {
2121
add(MParticlePackage())
22+
add(DeferredInitPackage())
2223
},
2324
)
2425
}
@@ -27,14 +28,24 @@ class MainApplication : Application(), ReactApplication {
2728
super.onCreate()
2829
loadReactNative(this)
2930

30-
val identityRequest = IdentityApiRequest.withEmptyUser()
31-
32-
val options = MParticleOptions.builder(this)
33-
.credentials("REPLACE_ME","REPLACE_ME")
34-
.logLevel(MParticle.LogLevel.VERBOSE)
35-
.identify(identityRequest.build())
36-
.build()
37-
38-
MParticle.start(options)
31+
if (DeferredInitModule.DEFERRED_INIT_EXAMPLE) {
32+
// Deferred-init edge case (see DeferredInitModule for the full explanation).
33+
// Register an eager activity tracker at process start -- before the first
34+
// Activity is created -- so logcat can contrast it against the deferred path.
35+
// MParticle.start() is intentionally NOT called here; DeferredInitModule
36+
// starts it at first-frame paint instead (see index.js).
37+
val eager = ActivityTracker("EAGER")
38+
registerActivityLifecycleCallbacks(eager)
39+
DeferredInitModule.eagerTracker = eager
40+
} else {
41+
// Standard, supported integration: start mParticle in Application.onCreate().
42+
val identityRequest = IdentityApiRequest.withEmptyUser()
43+
val options = MParticleOptions.builder(this)
44+
.credentials("REPLACE_ME", "REPLACE_ME")
45+
.logLevel(MParticle.LogLevel.VERBOSE)
46+
.identify(identityRequest.build())
47+
.build()
48+
MParticle.start(options)
49+
}
3950
}
4051
}

sample/index.js

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
View,
2020
TouchableOpacity,
2121
KeyboardAvoidingView,
22+
NativeModules,
2223
} from 'react-native';
2324
import MParticle from 'react-native-mparticle';
2425

@@ -170,6 +171,23 @@ export default class MParticleSample extends Component {
170171
}
171172

172173
componentDidMount() {
174+
// Deferred-init edge-case example (Android). When DeferredInitModule.DEFERRED_INIT_EXAMPLE
175+
// is enabled, mParticle is started from the native module at the moment the first frame is
176+
// painted, instead of in MainApplication.onCreate(). requestAnimationFrame fires after the
177+
// first frame is committed -- by which point MainActivity has already RESUMED, which is what
178+
// triggers the Rokt overlay Activity-capture race. When the flag is off (default) this is a
179+
// no-op. See DeferredInitModule.kt for the full explanation.
180+
requestAnimationFrame(() => {
181+
const {DeferredInit} = NativeModules;
182+
if (DeferredInit) {
183+
DeferredInit.startMParticle()
184+
.then(r => console.log('Deferred MParticle.start ->', r))
185+
.catch(e => console.warn('Deferred start failed', e));
186+
} else {
187+
console.warn('DeferredInit native module not available');
188+
}
189+
});
190+
173191
MParticle.getSession(session => this.setState({session}));
174192

175193
if (eventManagerEmitter) {

0 commit comments

Comments
 (0)