Skip to content

Commit c5f6350

Browse files
test(sample): add opt-in deferred-init edge-case example (Android) (#341)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent a62b96e commit c5f6350

6 files changed

Lines changed: 261 additions & 23 deletions

File tree

sample/README.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,34 @@ 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+
183+
> **Note:** When the flag is enabled, mParticle is not started until first-frame paint, so any
184+
> mParticle JS calls made earlier (e.g. `Identity.login` in the component constructor,
185+
> `getSession` in `componentDidMount`) run before the SDK is started and are no-ops until then.
186+
> This is itself an inherent hazard of deferred initialisation and is expected in this example;
187+
> gate such calls behind init completion in a real deferred-init integration. With the flag off
188+
> (default) mParticle starts eagerly in `onCreate()`, so these calls behave normally.
189+
162190
## Additional Resources
163191

164192
- [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: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
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+
// Idempotent: JS may invoke this more than once (Fast Refresh, remounts). Once mParticle
72+
// has started, skip re-registering the lifecycle observer and re-starting the SDK.
73+
if (MParticle.getInstance() != null) {
74+
promise.resolve("already started")
75+
return
76+
}
77+
78+
Log.i(TAG, "startMParticle() called from JS (post first-frame). MParticle.getInstance()=${MParticle.getInstance()}")
79+
80+
val app = reactContext.applicationContext as Application
81+
82+
// Mirror Rokt SDK: register the activity observer at init time, NOT at process start.
83+
app.registerActivityLifecycleCallbacks(deferredTracker)
84+
Log.i(TAG, "[DEFERRED] tracker registered. currentActivity right now = ${deferredTracker.currentActivity}")
85+
86+
val identityRequest = IdentityApiRequest.withEmptyUser()
87+
val options = MParticleOptions.builder(app)
88+
.credentials("REPLACE_ME", "REPLACE_ME")
89+
.logLevel(MParticle.LogLevel.VERBOSE)
90+
.identify(identityRequest.build())
91+
.build()
92+
93+
MParticle.start(options)
94+
Log.i(TAG, "MParticle.start() invoked from native module. getInstance()=${MParticle.getInstance()}")
95+
96+
// Snapshot the captured activity 2s later: did the deferred observer ever
97+
// see a resume? (Rokt's currentActivity cache works exactly this way.)
98+
Handler(Looper.getMainLooper()).postDelayed({
99+
Log.i(
100+
TAG,
101+
"SNAPSHOT after 2s -> EAGER.currentActivity=${eagerTracker?.currentActivity?.localClassName} | " +
102+
"DEFERRED.currentActivity=${deferredTracker.currentActivity?.localClassName}"
103+
)
104+
}, 2000)
105+
106+
promise.resolve("started")
107+
}
108+
109+
@ReactMethod
110+
fun reportActivityState(promise: Promise) {
111+
val msg = "EAGER=${eagerTracker?.currentActivity?.localClassName} | DEFERRED=${deferredTracker.currentActivity?.localClassName}"
112+
Log.i(TAG, "reportActivityState -> $msg")
113+
promise.resolve(msg)
114+
}
115+
}
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: 34 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -6,35 +6,46 @@ import com.facebook.react.ReactApplication
66
import com.facebook.react.ReactHost
77
import com.facebook.react.ReactNativeApplicationEntryPoint.loadReactNative
88
import com.facebook.react.defaults.DefaultReactHost.getDefaultReactHost
9-
import com.mparticle.react.MParticlePackage
109
import com.mparticle.MParticle
1110
import com.mparticle.MParticleOptions
1211
import com.mparticle.identity.IdentityApiRequest
12+
import com.mparticle.react.MParticlePackage
1313

1414
class MainApplication : Application(), ReactApplication {
1515

16-
override val reactHost: ReactHost by lazy {
17-
getDefaultReactHost(
18-
context = applicationContext,
19-
packageList =
20-
PackageList(this).packages.apply {
21-
add(MParticlePackage())
22-
},
23-
)
24-
}
25-
26-
override fun onCreate() {
27-
super.onCreate()
28-
loadReactNative(this)
29-
30-
val identityRequest = IdentityApiRequest.withEmptyUser()
16+
override val reactHost: ReactHost by lazy {
17+
getDefaultReactHost(
18+
context = applicationContext,
19+
packageList =
20+
PackageList(this).packages.apply {
21+
add(MParticlePackage())
22+
add(DeferredInitPackage())
23+
},
24+
)
25+
}
3126

32-
val options = MParticleOptions.builder(this)
33-
.credentials("REPLACE_ME","REPLACE_ME")
34-
.logLevel(MParticle.LogLevel.VERBOSE)
35-
.identify(identityRequest.build())
36-
.build()
27+
override fun onCreate() {
28+
super.onCreate()
29+
loadReactNative(this)
3730

38-
MParticle.start(options)
39-
}
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+
}
50+
}
4051
}

sample/index.js

Lines changed: 22 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,27 @@ export default class MParticleSample extends Component {
170171
}
171172

172173
componentDidMount() {
174+
// Deferred-init edge-case example (Android only). The DeferredInit native module exists only
175+
// on Android; iOS resolves the presenter lazily and is unaffected, so we skip it there to keep
176+
// iOS quiet. When DeferredInitModule.DEFERRED_INIT_EXAMPLE is enabled, mParticle is started
177+
// from the native module at the moment the first frame is painted, instead of in
178+
// MainApplication.onCreate(). requestAnimationFrame fires after the first frame is committed --
179+
// by which point MainActivity has already RESUMED, which is what triggers the Rokt overlay
180+
// Activity-capture race. When the flag is off (default) this is a no-op on the native side.
181+
// See DeferredInitModule.kt for the full explanation.
182+
if (Platform.OS === 'android') {
183+
requestAnimationFrame(() => {
184+
const {DeferredInit} = NativeModules;
185+
if (DeferredInit) {
186+
DeferredInit.startMParticle()
187+
.then(r => console.log('Deferred MParticle.start ->', r))
188+
.catch(e => console.warn('Deferred start failed', e));
189+
} else {
190+
console.warn('DeferredInit native module not available');
191+
}
192+
});
193+
}
194+
173195
MParticle.getSession(session => this.setState({session}));
174196

175197
if (eventManagerEmitter) {

0 commit comments

Comments
 (0)