Skip to content
Open
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
66 changes: 61 additions & 5 deletions sdk/src/main/java/cloud/mindbox/mobile_sdk/Mindbox.kt
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import cloud.mindbox.common.MindboxCommon
import cloud.mindbox.mobile_sdk.Mindbox.disposeDeviceUuidSubscription
import cloud.mindbox.mobile_sdk.Mindbox.disposePushTokenSubscription
import cloud.mindbox.mobile_sdk.Mindbox.handleRemoteMessage
import cloud.mindbox.mobile_sdk.Mindbox.registerInAppCallback
import cloud.mindbox.mobile_sdk.Mindbox.unregisterInAppCallback
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These two imports appear to be unused in code (only referenced in KDoc, and the KDoc links already resolve without importing members from the same object). With ktlint enabled in this repo (modulesCommon.gradle applies org.jlleitschuh.gradle.ktlint), unused imports will fail the lint task. Please remove these imports (or switch KDoc links to fully-qualified names if you intended to rely on imports for link resolution).

Suggested change
import cloud.mindbox.mobile_sdk.Mindbox.unregisterInAppCallback

Copilot uses AI. Check for mistakes.
import cloud.mindbox.mobile_sdk.di.MindboxDI
import cloud.mindbox.mobile_sdk.di.mindboxInject
import cloud.mindbox.mobile_sdk.inapp.data.managers.SessionStorageManager
Expand Down Expand Up @@ -735,18 +737,72 @@ public object Mindbox : MindboxLog {
}

/**
* Method to register callback for InApp Message
* Registers a callback for InApp messages.
*
* Call this method after you call [Mindbox.init]
* Call this method after [Mindbox.init]. The SDK holds a **strong reference** to
* [inAppCallback], so the callback persists until explicitly replaced or removed via
* [unregisterInAppCallback].
*
* @param inAppCallback used to provide required callback implementation
* Calling this method again replaces the previously registered callback.
*
* **Application-level callback (recommended):**
* Register once in `Application.onCreate` with a callback that does not reference any
* Activity. No cleanup needed.
* ```kotlin
* class MyApp : Application() {
* override fun onCreate() {
* super.onCreate()
* Mindbox.init(...)
* Mindbox.registerInAppCallback(MyGlobalInAppCallback())
* }
* }
* ```
*
* **Per-screen callback:**
* If different screens require different callback behavior and the callback captures an
* Activity reference, use `onResume`/`onPause` — **not** `onCreate`/`onDestroy`.
* Android guarantees that `onPause` of the current Activity is called before `onResume`
* of the next, so callbacks never overlap and the Activity reference is always cleared
* before the Activity can be garbage-collected.
Comment on lines +765 to +766
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The KDoc here claims that unregistering in onPause ensures the Activity reference is cleared before the Activity can be garbage-collected. In the current implementation, the callback is captured into InAppCallbackWrapper(callback: InAppCallback) when an in-app is shown (see InAppCallbackWrapper.kt), so any currently displayed/paused in-app can still hold the old callback (and its Activity reference) even after unregisterInAppCallback() is called. Please adjust the documentation to reflect that unregistering affects only future in-apps (or change the callback wiring so the wrapper delegates to the current callback rather than capturing a snapshot).

Suggested change
* of the next, so callbacks never overlap and the Activity reference is always cleared
* before the Activity can be garbage-collected.
* of the next, so callback registrations for future in-apps do not overlap.
* Note: `unregisterInAppCallback()` affects only future in-apps. An in-app that is
* already being shown may still hold the callback instance that was captured when it
* was displayed, so unregistering in `onPause` does not retroactively clear that
* reference.

Copilot uses AI. Check for mistakes.
* ```kotlin
* override fun onResume() {
* super.onResume()
* Mindbox.registerInAppCallback(myScreenCallback)
* }
* override fun onPause() {
* super.onPause()
* Mindbox.unregisterInAppCallback()
* }
* ```
*
* @param inAppCallback the callback implementation to register
**/

public fun registerInAppCallback(inAppCallback: InAppCallback) {
MindboxLoggerImpl.d(this, "registerInAppCallback")
mindboxLogI("InApp callback registered: ${inAppCallback::class.simpleName}")
inAppMessageManager.registerInAppCallback(inAppCallback)
}

/**
* Unregisters the current InApp message callback and restores the default SDK behavior.
*
* The default behavior handles URL redirects, deep links, payload copying, and logging
* automatically — the same actions performed when no custom callback is registered.
*
* **When to call:**
* Only needed for per-screen callbacks registered in `onResume`. Call in the corresponding
* `onPause` to release the Activity reference and restore default behavior while another
* screen is in the foreground.
Comment on lines +791 to +794
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This section says calling unregisterInAppCallback() in onPause will "release the Activity reference". Given the callback is copied into InAppCallbackWrapper at render time, an active/paused in-app may still retain the previous callback instance until it is dismissed/closed. Please either clarify this in the KDoc (e.g., recommend dismissing current in-app before unregistering if the callback captures an Activity) or update the implementation so active holders stop referencing the old callback after unregistration.

Copilot uses AI. Check for mistakes.
*
* Not needed if the callback was registered at the Application level and does not
* reference any Activity.
*
* @see registerInAppCallback
**/
public fun unregisterInAppCallback() {
mindboxLogI("InApp callback unregistered, default behavior restored")
inAppMessageManager.unregisterInAppCallback()
}

/**
* Method to initialise push services
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ internal interface InAppMessageManager {

fun registerInAppCallback(inAppCallback: InAppCallback)

fun unregisterInAppCallback()

fun initLogs()

fun onResumeCurrentActivity(activity: Activity)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ import cloud.mindbox.mobile_sdk.models.Milliseconds
import cloud.mindbox.mobile_sdk.models.Timestamp
import cloud.mindbox.mobile_sdk.monitoring.domain.interfaces.MonitoringInteractor
import cloud.mindbox.mobile_sdk.repository.MindboxPreferences
import cloud.mindbox.mobile_sdk.utils.LoggingExceptionHandler
import cloud.mindbox.mobile_sdk.utils.TimeProvider
import cloud.mindbox.mobile_sdk.utils.loggingRunCatching
import com.android.volley.VolleyError
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.collect
Expand All @@ -46,12 +46,6 @@ internal class InAppMessageManagerImpl(

private var processingJob: Job? = null

override fun registerCurrentActivity(activity: Activity) {
LoggingExceptionHandler.runCatching {
inAppMessageViewDisplayer.registerCurrentActivity(activity)
}
}

private val inAppScope =
CoroutineScope(defaultDispatcher + SupervisorJob() + Mindbox.coroutineExceptionHandler)

Expand Down Expand Up @@ -158,32 +152,32 @@ internal class InAppMessageManagerImpl(
monitoringInteractor.processLogs()
}

override fun registerInAppCallback(inAppCallback: InAppCallback) {
LoggingExceptionHandler.runCatching {
inAppMessageViewDisplayer.registerInAppCallback(inAppCallback)
}
override fun registerInAppCallback(inAppCallback: InAppCallback) = loggingRunCatching {
inAppMessageViewDisplayer.registerInAppCallback(inAppCallback)
}

override fun onPauseCurrentActivity(activity: Activity) {
LoggingExceptionHandler.runCatching {
inAppMessageViewDisplayer.onPauseCurrentActivity(activity)
}
override fun unregisterInAppCallback(): Unit = loggingRunCatching {
inAppMessageViewDisplayer.unregisterInAppCallback()
}

override fun onStopCurrentActivity(activity: Activity) {
LoggingExceptionHandler.runCatching {
inAppMessageViewDisplayer.onStopCurrentActivity(activity)
}
override fun registerCurrentActivity(activity: Activity): Unit = loggingRunCatching {
inAppMessageViewDisplayer.registerCurrentActivity(activity)
}

override fun onResumeCurrentActivity(activity: Activity) {
LoggingExceptionHandler.runCatching {
inAppMessageViewDisplayer.onResumeCurrentActivity(
activity = activity,
isNeedToShow = { !sessionStorageManager.isSessionExpiredOnLastCheck() },
onAppResumed = { inAppMessageDelayedManager.onAppResumed() }
)
}
override fun onPauseCurrentActivity(activity: Activity): Unit = loggingRunCatching {
inAppMessageViewDisplayer.onPauseCurrentActivity(activity)
}

override fun onStopCurrentActivity(activity: Activity): Unit = loggingRunCatching {
inAppMessageViewDisplayer.onStopCurrentActivity(activity)
}

override fun onResumeCurrentActivity(activity: Activity): Unit = loggingRunCatching {
inAppMessageViewDisplayer.onResumeCurrentActivity(
activity = activity,
isNeedToShow = { !sessionStorageManager.isSessionExpiredOnLastCheck() },
onAppResumed = { inAppMessageDelayedManager.onAppResumed() }
)
}

override fun handleSessionExpiration() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ internal interface InAppMessageViewDisplayer {

fun registerInAppCallback(inAppCallback: InAppCallback)

fun unregisterInAppCallback()

fun isInAppActive(): Boolean

fun dismissCurrentInApp()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,12 +46,16 @@ internal class InAppMessageViewDisplayerImpl(
}

private var currentActivity: Activity? = null
private var inAppCallback: InAppCallback = ComposableInAppCallback(

private val defaultCallback: InAppCallback = ComposableInAppCallback(
UrlInAppCallback(),
DeepLinkInAppCallback(),
CopyPayloadInAppCallback(),
LoggingInAppCallback()
)

private var inAppCallback: InAppCallback = defaultCallback

private val inAppQueue = LinkedList<InAppTypeWrapper<InAppType>>()

private var currentHolder: InAppViewHolder<*>? = null
Expand Down Expand Up @@ -108,6 +112,10 @@ internal class InAppMessageViewDisplayerImpl(
this.inAppCallback = inAppCallback
}

override fun unregisterInAppCallback() {
this.inAppCallback = defaultCallback
}

override fun isInAppActive(): Boolean = currentHolder?.isActive ?: false

override fun onStopCurrentActivity(activity: Activity) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
package cloud.mindbox.mobile_sdk.inapp.presentation

import cloud.mindbox.mobile_sdk.di.MindboxDI
import cloud.mindbox.mobile_sdk.inapp.presentation.callbacks.ComposableInAppCallback
import com.google.gson.Gson
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.unmockkAll
import org.junit.After
import org.junit.Assert.assertNotSame
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test

internal class InAppMessageViewDisplayerImplTest {

Expand All @@ -26,4 +31,63 @@ internal class InAppMessageViewDisplayerImplTest {
fun tearDown() {
unmockkAll()
}

@Test
fun `default callback is ComposableInAppCallback`() {
assertTrue(
"Default callback should be ComposableInAppCallback",
displayer.currentCallback() is ComposableInAppCallback
)
}

@Test
fun `registerInAppCallback replaces default callback`() {
val customCallback = mockk<InAppCallback>()

displayer.registerInAppCallback(customCallback)

assertSame(customCallback, displayer.currentCallback())
}

@Test
fun `unregisterInAppCallback restores default ComposableInAppCallback`() {
val customCallback = mockk<InAppCallback>()
displayer.registerInAppCallback(customCallback)

displayer.unregisterInAppCallback()

assertTrue(
"After unregister, callback should be restored to ComposableInAppCallback",
displayer.currentCallback() is ComposableInAppCallback
)
}

@Test
fun `registerInAppCallback replaces previously registered callback`() {
val callbackA = mockk<InAppCallback>()
val callbackB = mockk<InAppCallback>()

displayer.registerInAppCallback(callbackA)
displayer.registerInAppCallback(callbackB)

assertSame(callbackB, displayer.currentCallback())
assertNotSame(callbackA, displayer.currentCallback())
}

@Test
fun `unregisterInAppCallback after multiple registers restores default`() {
displayer.registerInAppCallback(mockk())
displayer.registerInAppCallback(mockk())

displayer.unregisterInAppCallback()

assertTrue(displayer.currentCallback() is ComposableInAppCallback)
}

// Accesses the private inAppCallback field via reflection
private fun InAppMessageViewDisplayerImpl.currentCallback(): InAppCallback {
val field = InAppMessageViewDisplayerImpl::class.java.getDeclaredField("inAppCallback")
field.isAccessible = true
return field.get(this) as InAppCallback
}
}
Loading