From 5e90616715e851f6d9dbe6e0f1a55f237bba1159 Mon Sep 17 00:00:00 2001 From: Egor Kitselyuk Date: Thu, 16 Apr 2026 16:29:50 +0300 Subject: [PATCH 1/3] MOBILE-114: Add public API unregisterInAppCallback --- .../java/cloud/mindbox/mobile_sdk/Mindbox.kt | 66 +++++++++++++++++-- .../inapp/presentation/InAppMessageManager.kt | 2 + .../presentation/InAppMessageManagerImpl.kt | 48 ++++++-------- .../presentation/InAppMessageViewDisplayer.kt | 2 + .../InAppMessageViewDisplayerImpl.kt | 10 ++- 5 files changed, 95 insertions(+), 33 deletions(-) diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/Mindbox.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/Mindbox.kt index 9ad7a86e..e4d9ac01 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/Mindbox.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/Mindbox.kt @@ -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 import cloud.mindbox.mobile_sdk.di.MindboxDI import cloud.mindbox.mobile_sdk.di.mindboxInject import cloud.mindbox.mobile_sdk.inapp.data.managers.SessionStorageManager @@ -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. + * ```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. + * + * 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 * diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageManager.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageManager.kt index 23906780..a4491134 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageManager.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageManager.kt @@ -16,6 +16,8 @@ internal interface InAppMessageManager { fun registerInAppCallback(inAppCallback: InAppCallback) + fun unregisterInAppCallback() + fun initLogs() fun onResumeCurrentActivity(activity: Activity) diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageManagerImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageManagerImpl.kt index 15edca03..f8ee03ee 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageManagerImpl.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageManagerImpl.kt @@ -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 @@ -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) @@ -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() { diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayer.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayer.kt index db2a4fdb..6caf48ff 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayer.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayer.kt @@ -22,6 +22,8 @@ internal interface InAppMessageViewDisplayer { fun registerInAppCallback(inAppCallback: InAppCallback) + fun unregisterInAppCallback() + fun isInAppActive(): Boolean fun dismissCurrentInApp() diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayerImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayerImpl.kt index 29bcc769..2fece1fa 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayerImpl.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayerImpl.kt @@ -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>() private var currentHolder: InAppViewHolder<*>? = null @@ -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) { From 43e8e1cdd637f78d0d05e33a53f2b0a165f75a0e Mon Sep 17 00:00:00 2001 From: Egor Kitselyuk Date: Thu, 16 Apr 2026 17:37:45 +0300 Subject: [PATCH 2/3] MOBILE-114: Add tests for unregisterInAppCallback --- .../InAppMessageViewDisplayerImplTest.kt | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayerImplTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayerImplTest.kt index 548652e7..6aa83190 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayerImplTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayerImplTest.kt @@ -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 { @@ -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() + + displayer.registerInAppCallback(customCallback) + + assertSame(customCallback, displayer.currentCallback()) + } + + @Test + fun `unregisterInAppCallback restores default ComposableInAppCallback`() { + val customCallback = mockk() + 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() + val callbackB = mockk() + + 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 + } } From 107b9b4b3bfd21d49894cb4d7c2d178fa0b09d4e Mon Sep 17 00:00:00 2001 From: Egor Kitselyuk Date: Fri, 17 Apr 2026 11:01:35 +0300 Subject: [PATCH 3/3] MOBILE-114: Change inAppCallback to inAppCallbackProvider --- sdk/src/main/java/cloud/mindbox/mobile_sdk/Mindbox.kt | 5 ----- .../mobile_sdk/inapp/presentation/InAppCallbackWrapper.kt | 6 +++--- .../inapp/presentation/InAppMessageViewDisplayerImpl.kt | 2 +- 3 files changed, 4 insertions(+), 9 deletions(-) diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/Mindbox.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/Mindbox.kt index e4d9ac01..5b9558ec 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/Mindbox.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/Mindbox.kt @@ -14,11 +14,6 @@ import androidx.lifecycle.Lifecycle.State.RESUMED import androidx.lifecycle.ProcessLifecycleOwner import androidx.work.WorkerFactory 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 import cloud.mindbox.mobile_sdk.di.MindboxDI import cloud.mindbox.mobile_sdk.di.mindboxInject import cloud.mindbox.mobile_sdk.inapp.data.managers.SessionStorageManager diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppCallbackWrapper.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppCallbackWrapper.kt index dfa7ff7c..42f3745c 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppCallbackWrapper.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppCallbackWrapper.kt @@ -1,16 +1,16 @@ package cloud.mindbox.mobile_sdk.inapp.presentation internal class InAppCallbackWrapper( - private val callback: InAppCallback, + private val callbackProvider: () -> InAppCallback, private val afterDismiss: () -> Unit = {}, ) : InAppCallback { override fun onInAppClick(id: String, redirectUrl: String, payload: String) { - callback.onInAppClick(id, redirectUrl, payload) + callbackProvider().onInAppClick(id, redirectUrl, payload) } override fun onInAppDismissed(id: String) { - callback.onInAppDismissed(id) + callbackProvider().onInAppDismissed(id) afterDismiss.invoke() } } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayerImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayerImpl.kt index 2fece1fa..93af840a 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayerImpl.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayerImpl.kt @@ -176,7 +176,7 @@ internal class InAppMessageViewDisplayerImpl( pausedHolder = null } - val callbackWrapper = InAppCallbackWrapper(inAppCallback) { + val callbackWrapper = InAppCallbackWrapper({ inAppCallback }) { wrapper.inAppActionCallbacks.onInAppDismiss.onDismiss() } val controller = InAppViewHolder.InAppController { closeInApp() }