diff --git a/sample/README.md b/sample/README.md index cc68665..383c7eb 100644 --- a/sample/README.md +++ b/sample/README.md @@ -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/) diff --git a/sample/android/app/src/main/java/com/mparticlesample/ActivityTracker.kt b/sample/android/app/src/main/java/com/mparticlesample/ActivityTracker.kt new file mode 100644 index 0000000..a35958c --- /dev/null +++ b/sample/android/app/src/main/java/com/mparticlesample/ActivityTracker.kt @@ -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? = 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) {} +} diff --git a/sample/android/app/src/main/java/com/mparticlesample/DeferredInitModule.kt b/sample/android/app/src/main/java/com/mparticlesample/DeferredInitModule.kt new file mode 100644 index 0000000..4705800 --- /dev/null +++ b/sample/android/app/src/main/java/com/mparticlesample/DeferredInitModule.kt @@ -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) + } +} diff --git a/sample/android/app/src/main/java/com/mparticlesample/DeferredInitPackage.kt b/sample/android/app/src/main/java/com/mparticlesample/DeferredInitPackage.kt new file mode 100644 index 0000000..a6bed18 --- /dev/null +++ b/sample/android/app/src/main/java/com/mparticlesample/DeferredInitPackage.kt @@ -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 = + listOf(DeferredInitModule(reactContext)) + + override fun createViewManagers(reactContext: ReactApplicationContext): List> = + emptyList() +} diff --git a/sample/android/app/src/main/java/com/mparticlesample/MainApplication.kt b/sample/android/app/src/main/java/com/mparticlesample/MainApplication.kt index e2f29d0..b8b6cf8 100644 --- a/sample/android/app/src/main/java/com/mparticlesample/MainApplication.kt +++ b/sample/android/app/src/main/java/com/mparticlesample/MainApplication.kt @@ -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) + } + } } diff --git a/sample/index.js b/sample/index.js index 476f61e..5851514 100644 --- a/sample/index.js +++ b/sample/index.js @@ -19,6 +19,7 @@ import { View, TouchableOpacity, KeyboardAvoidingView, + NativeModules, } from 'react-native'; import MParticle from 'react-native-mparticle'; @@ -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'); + } + }); + } + MParticle.getSession(session => this.setState({session})); if (eventManagerEmitter) {