From 87cf74b4e2422f769f04db40daf224c9a11e7192 Mon Sep 17 00:00:00 2001 From: Egor Kitselyuk Date: Mon, 10 Aug 2026 16:37:29 +0300 Subject: [PATCH 1/2] MOBILE-393: Fix push track-visit --- .../mobile_sdk/managers/LifecycleManager.kt | 28 ++++++- .../managers/LifecycleManagerTest.kt | 84 +++++++++++++++++-- 2 files changed, 100 insertions(+), 12 deletions(-) diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/managers/LifecycleManager.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/managers/LifecycleManager.kt index d8ab6c67..d69c5c58 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/managers/LifecycleManager.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/managers/LifecycleManager.kt @@ -116,6 +116,12 @@ internal class LifecycleManager internal constructor( private val intentHashes = mutableListOf() private var skipNextTrackVisit = false + @Volatile + private var pendingSource: String? = null + + @Volatile + private var pendingRequestUrl: String? = null + /** * True when [onMovedToForeground] was called while [currentIntent] was still null — * i.e. the app foregrounded before the first [onActivityStarted] callback arrived. @@ -216,6 +222,10 @@ internal class LifecycleManager internal constructor( if (!hasDeepLink && !isFromPush) return intentChanged = isNewHash(intent.hashCode()) + if (!intentChanged) { + mindboxLogI("onNewIntent. Intent already processed — skipping duplicate track visit.") + return + } sendTrackVisit(intent) skipNextTrackVisit = isAppInBackground } @@ -224,6 +234,8 @@ internal class LifecycleManager internal constructor( mindboxLogI("onAppMovedToBackground") isAppInBackground = true pendingVisit = false + pendingSource = null + pendingRequestUrl = null foregroundedWithoutIntent = false cancelKeepaliveTimer() } @@ -258,6 +270,10 @@ internal class LifecycleManager internal constructor( val cb = callbacks if (cb == null) { pendingVisit = true + if (pendingSource == null || pendingSource == DIRECT) { + pendingSource = source + pendingRequestUrl = if (source == LINK) intent?.data?.toString() else null + } mindboxLogI("Track visit pending (no callbacks yet)") return@loggingRunCatching } @@ -269,16 +285,20 @@ internal class LifecycleManager internal constructor( } /** - * Derives source and URL from the already-stored [currentIntent]/[intentChanged] and - * dispatches the track-visit through [cb]. + * Dispatches the track-visit through [cb] using the source/URL captured by [sendTrackVisit] + * while the visit was deferred — not recomputed from [currentIntent]/[intentChanged], which + * may have been overwritten by a later, unrelated recomputation (e.g. a wrapper replaying the + * same launch intent through [onNewIntent] before init completes). * * Called from the [callbacks] setter when [pendingVisit] is raised — the same pattern * iOS uses in `MBSessionManager` when `initializationCompleted` fires while `isActive` is true. */ private fun dispatchCurrentVisit(cb: Callbacks): Unit = loggingRunCatching { val intent = currentIntent ?: return@loggingRunCatching - val source = if (intentChanged) intentSource(intent) else DIRECT - val requestUrl = if (source == LINK) intent.data?.toString() else null + val source = pendingSource ?: if (intentChanged) intentSource(intent) else DIRECT + val requestUrl = pendingRequestUrl ?: if (source == LINK) intent.data?.toString() else null + pendingSource = null + pendingRequestUrl = null cb.onTrackVisitReady(source, requestUrl) startKeepaliveTimer() mindboxLogI("Track visit dispatched from pending state: source=$source url=$requestUrl") diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/managers/LifecycleManagerTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/managers/LifecycleManagerTest.kt index 54367ecc..d00178e7 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/managers/LifecycleManagerTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/managers/LifecycleManagerTest.kt @@ -196,15 +196,14 @@ internal class LifecycleManagerTest { } @Test - fun `repeated onNewIntent with same intent sends DIRECT on second call`() { + fun `repeated onNewIntent with same intent is a no-op on second call (MOBILE-393)`() { val manager = createManagerNoCallbacks() listenTrackVisit(manager) val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://example.com")) manager.onNewIntent(intent) manager.onNewIntent(intent) - assertEquals(2, trackVisitEvents.size) + assertEquals("duplicate call must not send a second, DIRECT-downgraded visit", 1, trackVisitEvents.size) assertEquals(LINK, trackVisitEvents[0].first) - assertEquals(DIRECT, trackVisitEvents[1].first) } // endregion @@ -712,14 +711,13 @@ internal class LifecycleManagerTest { } @Test - fun `onNewIntent sends DIRECT on second call with same intent`() { + fun `onNewIntent second call with same intent sends nothing (MOBILE-393)`() { val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://example.com")) val manager = createManager() manager.onNewIntent(intent) trackVisitEvents.clear() manager.onNewIntent(intent) - assertEquals(1, trackVisitEvents.size) - assertEquals(DIRECT, trackVisitEvents[0].first) + assertEquals("duplicate call must not send a second, DIRECT-downgraded visit", 0, trackVisitEvents.size) } @Test @@ -826,8 +824,8 @@ internal class LifecycleManagerTest { @Test fun `deeplink source derived from stored intent state on late callbacks set`() { - // Source (LINK) and URL are re-computed from currentIntent/intentChanged at dispatch time, - // not stored as parameters — same as iOS deriving visit info from stored state. + // Source (LINK) and URL are captured when the visit is deferred and replayed as-is + // at dispatch time (see MOBILE-393 region below for why they aren't recomputed). val manager = createManagerNoCallbacks(isAppInBackground = true) val owner = mockOwner() val url = "https://example.com/promo" @@ -1021,6 +1019,76 @@ internal class LifecycleManagerTest { } // endregion + + // region — MOBILE-393: pending source must not be downgraded by a repeated onNewIntent + // + // Regression: a wrapper (Flutter/RN) replays the same launch intent through onNewIntent + // before Mindbox.init() completes. That replay recomputes intentChanged as false (the hash + // was already consumed) and would compute DIRECT — but the push/link source captured while + // the visit was pending must win over that later recomputation. + + @Test + fun `repeated onNewIntent with same push intent before init does not downgrade pending source`() { + val manager = createManagerNoCallbacks(isAppInBackground = true) + val owner = mockOwner() + val intent = Intent().apply { putExtra(IS_OPENED_FROM_PUSH_BUNDLE_KEY, true) } + + // onActivityStarted (before init): consumes the intent hash, defers the visit + manager.onActivityStarted(buildActivityA(intent)) + manager.onStateChanged(owner, Lifecycle.Event.ON_START) + assertEquals("no dispatch yet — callbacks not set", 0, trackVisitEvents.size) + + // Wrapper replays the same launch intent via onNewIntent before init completes + manager.onNewIntent(intent) + + // Mindbox.init() sets callbacks — pending visit must dispatch as PUSH, not DIRECT + listenTrackVisit(manager) + assertEquals(1, trackVisitEvents.size) + assertEquals(PUSH, trackVisitEvents[0].first) + } + + @Test + fun `wrapper replaying launch intent via onNewIntent after init does not send a DIRECT visit`() { + // The actual production scenario: SDK already initialized (callbacks live), the + // Activity lifecycle already dispatched the correct PUSH visit via onActivityStarted, + // and the RN/Flutter wrapper's integration also calls Mindbox.onNewIntent(intent) for the + // same launch intent (documented pattern — must keep working without an app-side change). + val manager = createManager() + val intent = Intent().apply { putExtra(IS_OPENED_FROM_PUSH_BUNDLE_KEY, true) } + + manager.onActivityStarted(buildActivityA(intent)) + assertEquals(1, trackVisitEvents.size) + assertEquals(PUSH, trackVisitEvents[0].first) + + manager.onNewIntent(intent) + + assertEquals( + "duplicate onNewIntent for the already-tracked launch intent must not add a DIRECT visit", + 1, + trackVisitEvents.size, + ) + } + + @Test + fun `repeated onNewIntent with same deeplink intent before init does not downgrade pending source`() { + val manager = createManagerNoCallbacks(isAppInBackground = true) + val owner = mockOwner() + val url = "https://example.com/promo" + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) + + manager.onActivityStarted(buildActivityA(intent)) + manager.onStateChanged(owner, Lifecycle.Event.ON_START) + assertEquals(0, trackVisitEvents.size) + + manager.onNewIntent(intent) + + listenTrackVisit(manager) + assertEquals(1, trackVisitEvents.size) + assertEquals(LINK, trackVisitEvents[0].first) + assertEquals(url, trackVisitEvents[0].second) + } + + // endregion } private fun assertSame(expected: Any, actual: Any) { From c5162ca8a1339942a19fee2f6a6bcdd2a89834fa Mon Sep 17 00:00:00 2001 From: sozinov Date: Mon, 10 Aug 2026 23:32:08 +0300 Subject: [PATCH 2/2] MOBILE-393: Fix deferred track-visit drop and stale source on reinit --- .../mobile_sdk/managers/LifecycleManager.kt | 21 +++++++-- .../managers/LifecycleManagerTest.kt | 46 ++++++++++++++++++- 2 files changed, 63 insertions(+), 4 deletions(-) diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/managers/LifecycleManager.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/managers/LifecycleManager.kt index d69c5c58..b533f320 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/managers/LifecycleManager.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/managers/LifecycleManager.kt @@ -209,9 +209,14 @@ internal class LifecycleManager internal constructor( * Call this before replacing [callbacks] via [cloud.mindbox.mobile_sdk.Mindbox.init] * so the new endpoint receives a track-visit immediately upon reinitialisation. * The backend uses this signal to learn the device is now active in the new environment. + * + * A reinit is a *new* reason to visit, so any source captured for an earlier deferred visit + * is dropped here — it must be recomputed from the current intent state. */ fun scheduleReinitTrackVisit() { pendingVisit = true + pendingSource = null + pendingRequestUrl = null mindboxLogI("Track visit scheduled for reinit") } @@ -294,11 +299,21 @@ internal class LifecycleManager internal constructor( * iOS uses in `MBSessionManager` when `initializationCompleted` fires while `isActive` is true. */ private fun dispatchCurrentVisit(cb: Callbacks): Unit = loggingRunCatching { - val intent = currentIntent ?: return@loggingRunCatching - val source = pendingSource ?: if (intentChanged) intentSource(intent) else DIRECT - val requestUrl = pendingRequestUrl ?: if (source == LINK) intent.data?.toString() else null + val capturedSource = pendingSource + val capturedRequestUrl = pendingRequestUrl pendingSource = null pendingRequestUrl = null + + val source: String + val requestUrl: String? + if (capturedSource != null) { + source = capturedSource + requestUrl = capturedRequestUrl + } else { + val intent = currentIntent ?: return@loggingRunCatching + source = if (intentChanged) intentSource(intent) else DIRECT + requestUrl = if (source == LINK) intent.data?.toString() else null + } cb.onTrackVisitReady(source, requestUrl) startKeepaliveTimer() mindboxLogI("Track visit dispatched from pending state: source=$source url=$requestUrl") diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/managers/LifecycleManagerTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/managers/LifecycleManagerTest.kt index d00178e7..0c49fd20 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/managers/LifecycleManagerTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/managers/LifecycleManagerTest.kt @@ -1088,7 +1088,51 @@ internal class LifecycleManagerTest { assertEquals(url, trackVisitEvents[0].second) } - // endregion + @Test + fun `deferred push visit dispatched when init lands before the first onActivityStarted`() { + val manager = createManagerNoCallbacks(isAppInBackground = true) + val intent = Intent().apply { putExtra(IS_OPENED_FROM_PUSH_BUNDLE_KEY, true) } + + manager.onNewIntent(intent) + listenTrackVisit(manager) + + assertEquals("captured visit must not be dropped", 1, trackVisitEvents.size) + assertEquals(PUSH, trackVisitEvents[0].first) + } + + @Test + fun `deferred deeplink visit keeps its url when init lands before the first onActivityStarted`() { + val manager = createManagerNoCallbacks(isAppInBackground = true) + val url = "https://example.com/promo" + + manager.onNewIntent(Intent(Intent.ACTION_VIEW, Uri.parse(url))) + listenTrackVisit(manager) + + assertEquals(1, trackVisitEvents.size) + assertEquals(LINK, trackVisitEvents[0].first) + assertEquals(url, trackVisitEvents[0].second) + } + + @Test + fun `reinit visit is not replayed with the source captured for an earlier visit`() { + val manager = createManagerNoCallbacks(isAppInBackground = true) + val intent = Intent().apply { putExtra(IS_OPENED_FROM_PUSH_BUNDLE_KEY, true) } + + manager.onNewIntent(intent) + listenTrackVisit(manager) + manager.onActivityStarted(buildActivityA(intent)) + manager.onStateChanged(mockOwner(), Lifecycle.Event.ON_START) + trackVisitEvents.clear() + manager.scheduleReinitTrackVisit() + listenTrackVisit(manager) + + assertEquals("reinit must send exactly one visit", 1, trackVisitEvents.size) + assertEquals( + "reinit source must be recomputed, not the stale source of the earlier visit", + DIRECT, + trackVisitEvents[0].first, + ) + } } private fun assertSame(expected: Any, actual: Any) {