From 7dc8f0bd0d82c629895a5604b84f498f20c0089f Mon Sep 17 00:00:00 2001 From: Sergey Sozinov <103035673+sergeysozinov@users.noreply.github.com> Date: Fri, 3 Apr 2026 12:14:56 +0300 Subject: [PATCH 01/15] MOBILE-97: delete ios tests --- WebViewLocalStateStorageTests.swift | 279 ---------------------------- 1 file changed, 279 deletions(-) delete mode 100644 WebViewLocalStateStorageTests.swift diff --git a/WebViewLocalStateStorageTests.swift b/WebViewLocalStateStorageTests.swift deleted file mode 100644 index 26b0b44c..00000000 --- a/WebViewLocalStateStorageTests.swift +++ /dev/null @@ -1,279 +0,0 @@ -// -// WebViewLocalStateStorageTests.swift -// MindboxTests -// -// Created by Sergei Semko on 3/11/26. -// Copyright © 2026 Mindbox. All rights reserved. -// - -import Testing -@testable import Mindbox - -@Suite("WebViewLocalStateStorage", .tags(.webView)) -struct WebViewLocalStateStorageTests { - - private let testSuiteName = "cloud.Mindbox.test.webview.localState" - private let keyPrefix = Constants.WebViewLocalState.keyPrefix - - private func makeSUT() -> (sut: WebViewLocalStateStorage, defaults: UserDefaults, persistence: MockPersistenceStorage) { - let persistence = MockPersistenceStorage() - let defaults = UserDefaults(suiteName: testSuiteName)! - defaults.removePersistentDomain(forName: testSuiteName) - let sut = WebViewLocalStateStorage(dataDefaults: defaults, persistenceStorage: persistence) - return (sut, defaults, persistence) - } - - // MARK: - get - - @Test("get returns default version and empty data when storage is empty") - func getEmptyStorage() { - let (sut, _, _) = makeSUT() - - let state = sut.get(keys: []) - - #expect(state.version == Constants.WebViewLocalState.defaultVersion) - #expect(state.data.isEmpty) - } - - @Test("get returns all stored keys when keys array is empty") - func getAllKeys() { - let (sut, defaults, _) = makeSUT() - defaults.set("value1", forKey: "\(keyPrefix)key1") - defaults.set("value2", forKey: "\(keyPrefix)key2") - - let state = sut.get(keys: []) - - #expect(state.data.count == 2) - #expect(state.data["key1"] == "value1") - #expect(state.data["key2"] == "value2") - } - - @Test("get returns only requested keys") - func getSpecificKeys() { - let (sut, defaults, _) = makeSUT() - defaults.set("value1", forKey: "\(keyPrefix)key1") - defaults.set("value2", forKey: "\(keyPrefix)key2") - defaults.set("value3", forKey: "\(keyPrefix)key3") - - let state = sut.get(keys: ["key1", "key3"]) - - #expect(state.data.count == 2) - #expect(state.data["key1"] == "value1") - #expect(state.data["key3"] == "value3") - } - - @Test("get omits missing keys from data") - func getMissingKeys() { - let (sut, defaults, _) = makeSUT() - defaults.set("value1", forKey: "\(keyPrefix)key1") - - let state = sut.get(keys: ["key1", "missing"]) - - #expect(state.data.count == 1) - #expect(state.data["key1"] == "value1") - #expect(state.data["missing"] == nil) - } - - @Test("get returns current version from persistence") - func getCurrentVersion() { - let (sut, _, persistence) = makeSUT() - persistence.webViewLocalStateVersion = 5 - - let state = sut.get(keys: []) - - #expect(state.version == 5) - } - - @Test("get returns default version when persistence version is nil") - func getDefaultVersion() { - let (sut, _, persistence) = makeSUT() - persistence.webViewLocalStateVersion = nil - - let state = sut.get(keys: []) - - #expect(state.version == Constants.WebViewLocalState.defaultVersion) - } - - // MARK: - set - - @Test("set stores values in UserDefaults") - func setStoresValues() { - let (sut, defaults, _) = makeSUT() - - _ = sut.set(data: ["key1": "value1", "key2": "value2"]) - - #expect(defaults.string(forKey: "\(keyPrefix)key1") == "value1") - #expect(defaults.string(forKey: "\(keyPrefix)key2") == "value2") - } - - @Test("set removes key when value is nil") - func setRemovesNilKey() { - let (sut, defaults, _) = makeSUT() - defaults.set("value1", forKey: "\(keyPrefix)key1") - - _ = sut.set(data: ["key1": nil]) - - #expect(defaults.string(forKey: "\(keyPrefix)key1") == nil) - } - - @Test("set updates existing values") - func setUpdatesValues() { - let (sut, defaults, _) = makeSUT() - defaults.set("old", forKey: "\(keyPrefix)key1") - - let state = sut.set(data: ["key1": "new"]) - - #expect(defaults.string(forKey: "\(keyPrefix)key1") == "new") - #expect(state.data["key1"] == "new") - } - - @Test("set returns only affected keys") - func setReturnsAffectedKeys() { - let (sut, defaults, _) = makeSUT() - defaults.set("existing", forKey: "\(keyPrefix)existing") - - let state = sut.set(data: ["key1": "value1"]) - - #expect(state.data.count == 1) - #expect(state.data["key1"] == "value1") - #expect(state.data["existing"] == nil) - } - - @Test("set does not change version") - func setPreservesVersion() { - let (sut, _, persistence) = makeSUT() - persistence.webViewLocalStateVersion = 3 - - let state = sut.set(data: ["key1": "value1"]) - - #expect(state.version == 3) - #expect(persistence.webViewLocalStateVersion == 3) - } - - @Test("set stores each key as separate UserDefaults entry") - func setSeparateEntries() { - let (sut, defaults, _) = makeSUT() - - _ = sut.set(data: ["firstKey": "firstValue", "secondKey": "secondValue"]) - - #expect(defaults.string(forKey: "\(keyPrefix)firstKey") == "firstValue") - #expect(defaults.string(forKey: "\(keyPrefix)secondKey") == "secondValue") - } - - // MARK: - initialize - - @Test("initialize stores version in PersistenceStorage") - func initStoresVersion() { - let (sut, _, persistence) = makeSUT() - - _ = sut.initialize(version: 7, data: ["key": "value"]) - - #expect(persistence.webViewLocalStateVersion == 7) - } - - @Test("initialize stores data and returns it") - func initStoresAndReturnsData() throws { - let (sut, defaults, _) = makeSUT() - - let state = try #require(sut.initialize(version: 2, data: ["key1": "value1", "key2": "value2"])) - - #expect(state.version == 2) - #expect(state.data["key1"] == "value1") - #expect(state.data["key2"] == "value2") - #expect(defaults.string(forKey: "\(keyPrefix)key1") == "value1") - #expect(defaults.string(forKey: "\(keyPrefix)key2") == "value2") - } - - @Test("initialize rejects zero version") - func initRejectsZero() { - let (sut, _, _) = makeSUT() - - #expect(sut.initialize(version: 0, data: ["key": "value"]) == nil) - } - - @Test("initialize rejects negative version") - func initRejectsNegative() { - let (sut, _, _) = makeSUT() - - #expect(sut.initialize(version: -1, data: ["key": "value"]) == nil) - } - - @Test("initialize removes keys with nil values") - func initRemovesNilKeys() { - let (sut, defaults, _) = makeSUT() - defaults.set("value1", forKey: "\(keyPrefix)key1") - - let state = sut.initialize(version: 2, data: ["key1": nil]) - - #expect(state != nil) - #expect(defaults.string(forKey: "\(keyPrefix)key1") == nil) - } - - @Test("initialize merges with existing data") - func initMergesData() { - let (sut, defaults, _) = makeSUT() - defaults.set("existing", forKey: "\(keyPrefix)old") - - let state = sut.initialize(version: 3, data: ["new": "value"]) - - #expect(state != nil) - #expect(defaults.string(forKey: "\(keyPrefix)old") == "existing") - #expect(defaults.string(forKey: "\(keyPrefix)new") == "value") - } - - @Test("initialize does not store version on rejection") - func initPreservesVersionOnReject() { - let (sut, _, persistence) = makeSUT() - persistence.webViewLocalStateVersion = 5 - - _ = sut.initialize(version: 0, data: ["key": "value"]) - - #expect(persistence.webViewLocalStateVersion == 5) - } - - // MARK: - Integration - - @Test("full flow: init → set → get") - func fullFlow() throws { - let (sut, _, _) = makeSUT() - - let initState = try #require(sut.initialize(version: 2, data: ["key1": "value1", "key2": "value2"])) - #expect(initState.version == 2) - - let setState = sut.set(data: ["key1": "updated", "key2": nil, "key3": "value3"]) - #expect(setState.version == 2) - - let getState = sut.get(keys: []) - #expect(getState.version == 2) - #expect(getState.data["key1"] == "updated") - #expect(getState.data["key2"] == nil) - #expect(getState.data["key3"] == "value3") - } - - @Test("get after set with null returns empty for deleted key") - func setNullThenGet() { - let (sut, _, _) = makeSUT() - - _ = sut.set(data: ["key1": "value1"]) - _ = sut.set(data: ["key1": nil]) - - let state = sut.get(keys: ["key1"]) - #expect(state.data.isEmpty) - } - - @Test("prefix isolation: non-prefixed keys and Apple system keys are filtered out") - func prefixIsolation() { - let (sut, defaults, _) = makeSUT() - defaults.set("foreign", forKey: "foreignKey") - defaults.set("value", forKey: "\(keyPrefix)myKey") - - let state = sut.get(keys: []) - - #expect(state.data.count == 1) - #expect(state.data["myKey"] == "value") - #expect(state.data["foreignKey"] == nil) - #expect(state.data["AKLastLocale"] == nil) - #expect(state.data["AppleLocale"] == nil) - #expect(state.data["NSInterfaceStyle"] == nil) - } -} From 35d2bf5117c2276da9c06144a9c667a88566d130 Mon Sep 17 00:00:00 2001 From: Egor Kitselyuk Date: Tue, 7 Apr 2026 17:33:10 +0300 Subject: [PATCH 02/15] MOBILE-95: Fix dialogShown for push permission request from js --- .../presentation/actions/PushActivationActivity.kt | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/actions/PushActivationActivity.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/actions/PushActivationActivity.kt index d78d6e11..b1a4e537 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/actions/PushActivationActivity.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/actions/PushActivationActivity.kt @@ -58,20 +58,20 @@ internal class PushActivationActivity : Activity() { mindboxLogI("User already rejected permission two times, try open settings") mindboxNotificationManager.openNotificationSettings(this) } - finishWithResult(isGranted = false, dialogShown = false) + finishWithResult(isGranted = false, dialogShown = isDialogLikelyShown()) } else { mindboxLogI("Awaiting show dialog") shouldCheckDialogShowing = true } } else { mindboxNotificationManager.shouldOpenSettings = true - finishWithResult(isGranted = false) + finishWithResult(isGranted = false, dialogShown = isDialogLikelyShown()) } } permissionDenied && shouldShowRationale -> { mindboxLogI("User rejected first permission request") - finishWithResult(isGranted = false) + finishWithResult(isGranted = false, dialogShown = isDialogLikelyShown()) } } } @@ -124,6 +124,10 @@ internal class PushActivationActivity : Activity() { super.onDestroy() } + private fun isDialogLikelyShown(): Boolean = resumeTimes.lastOrNull()?.let { lastResume -> + SystemClock.elapsedRealtime() - lastResume >= TIME_BETWEEN_RESUME + } ?: false + private fun finishWithResult(isGranted: Boolean, dialogShown: Boolean = true) { RuntimePermissionRequestBridge.resolve(requestId.orEmpty(), isGranted, dialogShown) isResultSent = true From 5e90616715e851f6d9dbe6e0f1a55f237bba1159 Mon Sep 17 00:00:00 2001 From: Egor Kitselyuk Date: Thu, 16 Apr 2026 16:29:50 +0300 Subject: [PATCH 03/15] 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 04/15] 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 05/15] 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() } From 70d1ee63b9981832b74bd34bbe923b1aa70f2112 Mon Sep 17 00:00:00 2001 From: Egor Kitselyuk Date: Thu, 12 Mar 2026 13:38:28 +0300 Subject: [PATCH 06/15] MOBILEWEBVIEW-117: Update rustore version --- example/app/build.gradle | 2 +- gradle/libs.versions.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/example/app/build.gradle b/example/app/build.gradle index 9e21cd21..1248502d 100644 --- a/example/app/build.gradle +++ b/example/app/build.gradle @@ -87,7 +87,7 @@ dependencies { implementation 'com.google.firebase:firebase-analytics-ktx' implementation 'com.google.firebase:firebase-messaging-ktx' implementation 'com.huawei.hms:push:6.11.0.300' - implementation 'ru.rustore.sdk:pushclient:6.10.0' + implementation 'ru.rustore.sdk:pushclient:7.2.0' implementation 'com.google.code.gson:gson:2.11.0' diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 87cf36ca..1ce4ed8e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -40,7 +40,7 @@ ktlint-plugin = "12.1.1" ksp = "1.9.22-1.0.17" maven_publish = "0.32.0" -pushclient = "6.10.0" +pushclient = "7.2.0" [bundles] buildscript-plugins = [ From 3be75ea9aaf0ce6cda8ec5f66f7f79ead020bdf2 Mon Sep 17 00:00:00 2001 From: Egor Kitselyuk Date: Mon, 27 Apr 2026 16:32:28 +0300 Subject: [PATCH 07/15] MOBILE-129: Add operationsDomain --- .../java/cloud/mindbox/mobile_sdk/Mindbox.kt | 26 +++----- .../mobile_sdk/MindboxConfiguration.kt | 21 ++++++- .../cloud/mindbox/mobile_sdk/SdkValidation.kt | 62 +++++++++++++++---- .../MobileConfigSerializationManagerImpl.kt | 8 ++- .../MobileConfigRepositoryImpl.kt | 33 +++++++++- .../OperationsDomainConfigPolicy.kt | 27 ++++++++ .../mobile_sdk/managers/GatewayManager.kt | 29 ++++++++- .../mobile_sdk/models/Configuration.kt | 6 +- .../operation/response/InAppConfigResponse.kt | 19 +++++- .../mobile_sdk/repository/MindboxDatabase.kt | 16 ++++- .../repository/MindboxPreferences.kt | 17 +++++ 11 files changed, 220 insertions(+), 44 deletions(-) create mode 100644 sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/OperationsDomainConfigPolicy.kt 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 5b9558ec..25a12a5d 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/Mindbox.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/Mindbox.kt @@ -1437,6 +1437,7 @@ public object Mindbox : MindboxLog { endpointId = configuration.endpointId, previousDeviceUUID = configuration.previousDeviceUUID, previousInstallationId = configuration.previousInstallationId, + operationsDomain = configuration.operationsDomain, ) return if (validationErrors.isEmpty()) { @@ -1446,27 +1447,14 @@ public object Mindbox : MindboxLog { throw InitializeMindboxException(validationErrors.toString()) } MindboxLoggerImpl.e(this, "Invalid configuration parameters found: $validationErrors") - val isDeviceIdError = validationErrors.contains( - SdkValidation.Error.INVALID_DEVICE_ID, - ) - val isInstallationIdError = validationErrors.contains( - SdkValidation.Error.INVALID_INSTALLATION_ID, - ) - - val previousDeviceUUID = if (isDeviceIdError) { - "" - } else { - configuration.previousDeviceUUID - } - val previousInstallationId = if (isInstallationIdError) { - "" - } else { - configuration.previousInstallationId - } + val isDeviceIdError = validationErrors.contains(SdkValidation.Error.INVALID_DEVICE_ID) + val isInstallationIdError = validationErrors.contains(SdkValidation.Error.INVALID_INSTALLATION_ID) + val isOperationsDomainError = validationErrors.contains(SdkValidation.Error.INVALID_OPERATIONS_DOMAIN) configuration.copy( - previousDeviceUUID = previousDeviceUUID, - previousInstallationId = previousInstallationId, + previousDeviceUUID = if (isDeviceIdError) "" else configuration.previousDeviceUUID, + previousInstallationId = if (isInstallationIdError) "" else configuration.previousInstallationId, + operationsDomain = if (isOperationsDomainError) null else configuration.operationsDomain, ) } } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/MindboxConfiguration.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/MindboxConfiguration.kt index fbf90f7e..b6c10f98 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/MindboxConfiguration.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/MindboxConfiguration.kt @@ -22,6 +22,7 @@ public class MindboxConfiguration private constructor( internal val subscribeCustomerIfCreated: Boolean, internal val shouldCreateCustomer: Boolean, internal val uuidDebugEnabled: Boolean, + internal val operationsDomain: String? = null, ) { public constructor(builder: Builder) : this( @@ -35,6 +36,7 @@ public class MindboxConfiguration private constructor( subscribeCustomerIfCreated = builder.subscribeCustomerIfCreated, shouldCreateCustomer = builder.shouldCreateCustomer, uuidDebugEnabled = builder.uuidDebugEnabled, + operationsDomain = builder.operationsDomain, ) internal fun copy( @@ -48,6 +50,7 @@ public class MindboxConfiguration private constructor( subscribeCustomerIfCreated: Boolean = this.subscribeCustomerIfCreated, shouldCreateCustomer: Boolean = this.shouldCreateCustomer, uuidDebugEnabled: Boolean = this.uuidDebugEnabled, + operationsDomain: String? = this.operationsDomain, ) = MindboxConfiguration( previousInstallationId = previousInstallationId, previousDeviceUUID = previousDeviceUUID, @@ -59,6 +62,7 @@ public class MindboxConfiguration private constructor( subscribeCustomerIfCreated = subscribeCustomerIfCreated, shouldCreateCustomer = shouldCreateCustomer, uuidDebugEnabled = uuidDebugEnabled, + operationsDomain = operationsDomain, ) override fun toString(): String { @@ -71,7 +75,8 @@ public class MindboxConfiguration private constructor( "versionCode = $versionCode, " + "subscribeCustomerIfCreated = $subscribeCustomerIfCreated, " + "shouldCreateCustomer = $shouldCreateCustomer, " + - "uuidDebugEnabled = $uuidDebugEnabled)" + "uuidDebugEnabled = $uuidDebugEnabled, " + + "operationsDomain = $operationsDomain)" } /** @@ -94,6 +99,7 @@ public class MindboxConfiguration private constructor( internal var versionCode: String = PLACEHOLDER_APP_VERSION_CODE internal var shouldCreateCustomer: Boolean = true internal var uuidDebugEnabled: Boolean = true + internal var operationsDomain: String? = null /** * Specifies deviceUUID for Mindbox @@ -149,6 +155,17 @@ public class MindboxConfiguration private constructor( return this } + /** + * Optional host for operations (/v3/operations/async, /v3/operations/sync, + * /v1.1/customer/mobile-track-visit). Use when your project routes operations through + * an anonymizer proxy. A blank value is treated as not set. An invalid value is logged + * and ignored during SDK initialization. + */ + public fun operationsDomain(operationsDomain: String): Builder { + this.operationsDomain = operationsDomain.trim().takeIf { it.isNotBlank() } + return this + } + /** * Creates a new MindboxConfiguration.Builder. */ @@ -175,7 +192,7 @@ public class MindboxConfiguration private constructor( // need for scheduling and stopping one-time background service SharedPreferencesManager.with(context) MindboxPreferences.hostAppName = packageName - } catch (e: Exception) { + } catch (_: Exception) { MindboxLoggerImpl.e( this, "Getting app info failed. Identified as an unknown application", diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/SdkValidation.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/SdkValidation.kt index 079233fe..6f0e91c7 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/SdkValidation.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/SdkValidation.kt @@ -8,26 +8,66 @@ internal object SdkValidation { enum class Error(val critical: Boolean, val message: String) { EMPTY_DOMAIN(true, "Domain must not be empty"), - INVALID_FORMAT_DOMAIN(true, "The domain must not start with https:// and must not end with /"), + INVALID_FORMAT_DOMAIN(true, "The domain format is not valid"), INVALID_DOMAIN(true, "The domain is not valid"), EMPTY_ENDPOINT(true, "Endpoint must not be empty"), INVALID_DEVICE_ID(false, "Invalid previous device UUID format"), - INVALID_INSTALLATION_ID(false, "Invalid UUID format of previous installationId"); + INVALID_INSTALLATION_ID(false, "Invalid UUID format of previous installationId"), + INVALID_OPERATIONS_DOMAIN(false, "The operationsDomain is not valid, it will be ignored"); override fun toString() = "$name(critical=$critical, message=$message)" } + /** + * Strips http:// or https:// scheme and trailing slashes from [input]. + * "https://api.mindbox.ru/" → "api.mindbox.ru" + * "api.mindbox.ru/" → "api.mindbox.ru" + */ + fun extractHost(input: String): String = + input.trim() + .removePrefix("https://") + .removePrefix("http://") + .trimEnd('/') + + /** + * Returns a full base URL. If [hostOrUrl] already contains a scheme (http:// or https://), + * it is preserved. Otherwise https:// is prepended. + * "api.mindbox.ru" → "https://api.mindbox.ru" + * "http://proxy.example.com" → "http://proxy.example.com" + */ + fun toBaseUrl(hostOrUrl: String): String = + if (hostOrUrl.startsWith("http://") || hostOrUrl.startsWith("https://")) { + hostOrUrl.trimEnd('/') + } else { + "https://${hostOrUrl.trimEnd('/')}" + } + + /** + * Returns true if [domain] is a valid domain host, accepting optional http:// or https:// prefix + * and optional trailing slash. + */ + fun isValidDomain(domain: String): Boolean { + val host = extractHost(domain) + return host.isNotBlank() && isDomainValid(host) + } + fun validateConfiguration( domain: String, endpointId: String, previousDeviceUUID: String, - previousInstallationId: String + previousInstallationId: String, + operationsDomain: String? = null, ) = LoggingExceptionHandler.runCatching(defaultValue = listOf()) { mutableListOf().apply { when { domain.isBlank() -> add(Error.EMPTY_DOMAIN) - !isDomainWellFormatted(domain) -> add(Error.INVALID_FORMAT_DOMAIN) - !isDomainValid(domain) -> add(Error.INVALID_DOMAIN) + else -> { + val host = extractHost(domain) + when { + host.isBlank() -> add(Error.INVALID_FORMAT_DOMAIN) + !isDomainValid(host) -> add(Error.INVALID_DOMAIN) + } + } } if (endpointId.isBlank()) { @@ -41,14 +81,12 @@ internal object SdkValidation { if (previousInstallationId.isNotEmpty() && !previousInstallationId.isUuid()) { add(Error.INVALID_INSTALLATION_ID) } + + if (operationsDomain != null && !isValidDomain(operationsDomain)) { + add(Error.INVALID_OPERATIONS_DOMAIN) + } } } - private fun isDomainWellFormatted(domain: String) = !domain.startsWith("http") && - !domain.startsWith("/") && - !domain.endsWith("/") - - private fun isDomainValid( - domain: String - ) = PatternsCompat.DOMAIN_NAME.matcher(domain).matches() + private fun isDomainValid(domain: String) = PatternsCompat.DOMAIN_NAME.matcher(domain).matches() } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/MobileConfigSerializationManagerImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/MobileConfigSerializationManagerImpl.kt index 2dd127f1..09d7a665 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/MobileConfigSerializationManagerImpl.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/MobileConfigSerializationManagerImpl.kt @@ -121,7 +121,13 @@ internal class MobileConfigSerializationManagerImpl(private val gson: Gson) : mindboxLogE("Failed to parse featureToggles block in settings section") } - SettingsDtoBlank(operations, ttl, slidingExpiration, inappSettings, featureToggles) + val baseAddresses = runCatching { + gson.fromJson(json.asJsonObject.get("baseAddresses"), BaseAddressesDtoBlank::class.java)?.copy() + }.getOrNull { + mindboxLogE("Failed to parse baseAddresses block in settings section") + } + + SettingsDtoBlank(operations, ttl, slidingExpiration, inappSettings, featureToggles, baseAddresses) } }.getOrNull { mindboxLogE("Failed to parse settings block", it) diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/MobileConfigRepositoryImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/MobileConfigRepositoryImpl.kt index 097abc99..26812e53 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/MobileConfigRepositoryImpl.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/MobileConfigRepositoryImpl.kt @@ -103,6 +103,7 @@ internal class MobileConfigRepositoryImpl( mobileConfigSettingsManager.checkPushTokenKeepalive(config = filteredConfig) inappSettingsManager.applySettings(config = filteredConfig) featureToggleManager.applyToggles(config = filteredConfig) + persistOperationsDomain(filteredConfig) configState.value = updatedInAppConfig mindboxLogI(message = "Providing config: $updatedInAppConfig") } @@ -190,7 +191,37 @@ internal class MobileConfigRepositoryImpl( mindboxLogW("Unable to get featureToggles settings $it") } - return SettingsDto(operations, ttl, slidingExpiration, inappSettings, featureToggles) + val baseAddresses = runCatching { getBaseAddresses(configBlank) }.getOrNull { + mindboxLogW("Unable to get baseAddresses settings $it") + } + + return SettingsDto(operations, ttl, slidingExpiration, inappSettings, featureToggles, baseAddresses) + } + + private fun getBaseAddresses(configBlank: InAppConfigResponseBlank?): BaseAddressesDto? { + val operations = configBlank?.settings?.baseAddresses?.operations + ?.trim() + ?.takeIf { it.isNotBlank() } + ?: return null + return BaseAddressesDto(operations = operations) + } + + private fun persistOperationsDomain(config: InAppConfigResponse) { + val raw = config.settings?.baseAddresses?.operations + val stored = MindboxPreferences.operationsDomainFromConfig + when (val action = OperationsDomainConfigPolicy.action(raw, stored)) { + is OperationsDomainConfigPolicyAction.Save -> { + mindboxLogD("operationsDomain: saving '${action.value}'") + MindboxPreferences.operationsDomainFromConfig = action.value + } + is OperationsDomainConfigPolicyAction.Clear -> { + mindboxLogD("operationsDomain: clearing stored value '$stored'") + MindboxPreferences.operationsDomainFromConfig = null + } + is OperationsDomainConfigPolicyAction.Keep -> { + mindboxLogD("operationsDomain: keeping existing value '$stored'") + } + } } private fun getInAppTtl(configBlank: InAppConfigResponseBlank?): TtlDto? = diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/OperationsDomainConfigPolicy.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/OperationsDomainConfigPolicy.kt new file mode 100644 index 00000000..b7efc508 --- /dev/null +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/OperationsDomainConfigPolicy.kt @@ -0,0 +1,27 @@ +package cloud.mindbox.mobile_sdk.inapp.data.repositories + +import cloud.mindbox.mobile_sdk.SdkValidation + +internal sealed class OperationsDomainConfigPolicyAction { + data class Save(val value: String) : OperationsDomainConfigPolicyAction() + + object Clear : OperationsDomainConfigPolicyAction() + + object Keep : OperationsDomainConfigPolicyAction() +} + +internal object OperationsDomainConfigPolicy { + + fun action(raw: String?, currentlyStored: String?): OperationsDomainConfigPolicyAction { + val value = raw?.trim()?.takeIf { it.isNotBlank() } + ?: return OperationsDomainConfigPolicyAction.Keep + + if (!SdkValidation.isValidDomain(value)) return OperationsDomainConfigPolicyAction.Keep + + return if (value == currentlyStored) { + OperationsDomainConfigPolicyAction.Keep + } else { + OperationsDomainConfigPolicyAction.Save(value) + } + } +} diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/managers/GatewayManager.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/managers/GatewayManager.kt index 7a856bef..e4b269e8 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/managers/GatewayManager.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/managers/GatewayManager.kt @@ -2,6 +2,7 @@ package cloud.mindbox.mobile_sdk.managers import android.util.Log import androidx.annotation.VisibleForTesting +import cloud.mindbox.mobile_sdk.SdkValidation import cloud.mindbox.mobile_sdk.fromJsonTyped import cloud.mindbox.mobile_sdk.inapp.data.dto.GeoTargetingDto import cloud.mindbox.mobile_sdk.inapp.domain.models.* @@ -83,7 +84,27 @@ internal class GatewayManager(private val mindboxServiceGenerator: MindboxServic } private fun getConfigUrl(configuration: Configuration): String { - return "https://${configuration.domain}/mobile/byendpoint/${configuration.endpointId}.json" + return "${SdkValidation.toBaseUrl(configuration.domain)}/mobile/byendpoint/${configuration.endpointId}.json" + } + + /** + * Resolves the host to use for operations endpoints using the priority: + * 1. operationsDomainFromConfig — settings.baseAddresses.operations from the remote mobile config + * 2. operationsDomain from Mindbox.init configuration + * 3. domain from Mindbox.init configuration (fallback, preserves backward compatibility) + */ + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal fun resolveOperationsDomain( + configuration: Configuration, + operationsDomainFromConfig: String?, + ): String { + operationsDomainFromConfig + ?.takeIf { it.isNotBlank() } + ?.let { return it } + configuration.operationsDomain + ?.takeIf { it.isNotBlank() } + ?.let { return it } + return configuration.domain } private fun buildEventUrl( @@ -123,7 +144,9 @@ internal class GatewayManager(private val mindboxServiceGenerator: MindboxServic } } - return "https://${configuration.domain}${event.eventType.endpoint}${urlQueries.toUrlQueryString()}" + val domain = resolveOperationsDomain(configuration, MindboxPreferences.operationsDomainFromConfig) + val baseUrl = SdkValidation.toBaseUrl(domain) + return "$baseUrl${event.eventType.endpoint}${urlQueries.toUrlQueryString()}" } fun sendAsyncEvent( @@ -328,7 +351,7 @@ internal class GatewayManager(private val mindboxServiceGenerator: MindboxServic mindboxServiceGenerator.addToRequestQueue( MindboxRequest( Request.Method.GET, - "https://${configuration.domain}/geo", + "${SdkValidation.toBaseUrl(configuration.domain)}/geo", configuration, null, { response -> diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/models/Configuration.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/models/Configuration.kt index 6d420043..c0b664e0 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/models/Configuration.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/models/Configuration.kt @@ -18,7 +18,8 @@ internal data class Configuration( val versionName: String, val versionCode: String, val subscribeCustomerIfCreated: Boolean, - val shouldCreateCustomer: Boolean + val shouldCreateCustomer: Boolean, + val operationsDomain: String? = null, ) { internal constructor(mindboxConfiguration: MindboxConfiguration) : this( @@ -30,7 +31,8 @@ internal data class Configuration( versionName = mindboxConfiguration.versionName, versionCode = mindboxConfiguration.versionCode, subscribeCustomerIfCreated = mindboxConfiguration.subscribeCustomerIfCreated, - shouldCreateCustomer = mindboxConfiguration.shouldCreateCustomer + shouldCreateCustomer = mindboxConfiguration.shouldCreateCustomer, + operationsDomain = mindboxConfiguration.operationsDomain, ) } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/models/operation/response/InAppConfigResponse.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/models/operation/response/InAppConfigResponse.kt index f42155df..de99b37d 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/models/operation/response/InAppConfigResponse.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/models/operation/response/InAppConfigResponse.kt @@ -35,8 +35,16 @@ internal data class SettingsDtoBlank( @SerializedName("inapp") val inappSettings: InappSettingsDtoBlank?, @SerializedName("featureToggles") - val featureToggles: FeatureTogglesDtoBlank? + val featureToggles: FeatureTogglesDtoBlank?, + @SerializedName("baseAddresses") + val baseAddresses: BaseAddressesDtoBlank?, ) { + + internal data class BaseAddressesDtoBlank( + @SerializedName("operations") + val operations: String?, + ) + internal data class OperationDtoBlank( @SerializedName("systemName") val systemName: String @@ -81,7 +89,14 @@ internal data class SettingsDto( @SerializedName("inapp") val inapp: InappSettingsDto?, @SerializedName("featureToggles") - val featureToggles: Map? + val featureToggles: Map?, + @SerializedName("baseAddresses") + val baseAddresses: BaseAddressesDto? = null, +) + +internal data class BaseAddressesDto( + @SerializedName("operations") + val operations: String?, ) internal data class OperationDto( diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/repository/MindboxDatabase.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/repository/MindboxDatabase.kt index 8a3214a1..ddf82ac7 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/repository/MindboxDatabase.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/repository/MindboxDatabase.kt @@ -14,7 +14,7 @@ import cloud.mindbox.mobile_sdk.managers.DbManager.CONFIGURATION_TABLE_NAME import cloud.mindbox.mobile_sdk.models.Configuration import cloud.mindbox.mobile_sdk.models.Event -@Database(entities = [Configuration::class, Event::class], exportSchema = false, version = 2) +@Database(entities = [Configuration::class, Event::class], exportSchema = false, version = 3) @TypeConverters(MindboxRoomConverter::class) internal abstract class MindboxDatabase : RoomDatabase() { @@ -31,6 +31,15 @@ internal abstract class MindboxDatabase : RoomDatabase() { } } + private val MIGRATION_2_3 = object : Migration(2, 3) { + + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + "ALTER TABLE $CONFIGURATION_TABLE_NAME ADD COLUMN operationsDomain TEXT" + ) + } + } + internal var isTestMode = false internal fun getInstance(context: Context) = if (!isTestMode) { @@ -39,7 +48,10 @@ internal abstract class MindboxDatabase : RoomDatabase() { context.applicationContext, MindboxDatabase::class.java, DATABASE_NAME, - ).addMigrations(MIGRATION_1_2) + ).addMigrations( + MIGRATION_1_2, + MIGRATION_2_3 + ) .build() } else { Room diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/repository/MindboxPreferences.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/repository/MindboxPreferences.kt index cf325fdf..e8b631d8 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/repository/MindboxPreferences.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/repository/MindboxPreferences.kt @@ -39,6 +39,7 @@ internal object MindboxPreferences { private const val KEY_LOCAL_STATE_VERSION = "local_state_version" private const val DEFAULT_LOCAL_STATE_VERSION = 1 private const val KEY_FIRST_INITIALIZATION_TIME = "key_first_initialization_time" + private const val KEY_OPERATIONS_DOMAIN_FROM_CONFIG = "key_operations_domain_from_config" private val prefScope = CoroutineScope(Dispatchers.Default) @@ -276,4 +277,20 @@ internal object MindboxPreferences { SharedPreferencesManager.put(KEY_LOCAL_STATE_VERSION, value) } } + + /** + * Operations domain received from the remote mobile config (settings.baseAddresses.operations). + * Takes priority over [cloud.mindbox.mobile_sdk.MindboxConfiguration.Builder.operationsDomain] set at init time. + * Supports rollback: cleared when the remote config omits the field and a value was stored. + */ + var operationsDomainFromConfig: String? + get() = loggingRunCatching(defaultValue = null) { + SharedPreferencesManager.getString(KEY_OPERATIONS_DOMAIN_FROM_CONFIG) + ?.takeIf { it.isNotBlank() } + } + set(value) { + loggingRunCatching { + SharedPreferencesManager.put(KEY_OPERATIONS_DOMAIN_FROM_CONFIG, value) + } + } } From 08884ccd4b15a0508e0c8ccaa2017e80676abff9 Mon Sep 17 00:00:00 2001 From: Egor Kitselyuk Date: Mon, 27 Apr 2026 16:34:59 +0300 Subject: [PATCH 08/15] MOBILE-129: Add test for operationsDomain --- .../mobile_sdk/InputParametersUnitTest.kt | 56 ++---- .../mobile_sdk/managers/GatewayManager.kt | 2 +- .../mobile_sdk/SdkValidationDomainTest.kt | 75 ++++++++ .../OperationsDomainConfigPolicyTest.kt | 169 ++++++++++++++++++ .../mobile_sdk/managers/GatewayManagerTest.kt | 167 +++++++++++++++++ .../MobileConfigSettingsManagerTest.kt | 2 +- 6 files changed, 431 insertions(+), 40 deletions(-) create mode 100644 sdk/src/test/java/cloud/mindbox/mobile_sdk/SdkValidationDomainTest.kt create mode 100644 sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/OperationsDomainConfigPolicyTest.kt diff --git a/sdk/src/androidTest/java/cloud/mindbox/mobile_sdk/InputParametersUnitTest.kt b/sdk/src/androidTest/java/cloud/mindbox/mobile_sdk/InputParametersUnitTest.kt index 3f511aca..2355fca4 100644 --- a/sdk/src/androidTest/java/cloud/mindbox/mobile_sdk/InputParametersUnitTest.kt +++ b/sdk/src/androidTest/java/cloud/mindbox/mobile_sdk/InputParametersUnitTest.kt @@ -7,13 +7,16 @@ class InputParametersUnitTest { private val wrongDomainParameter = arrayListOf( "", - "https://api.mindbox.ru", - "api.mindbox.ru/", - "https://api.mindbox.ru/", "hgkkjhhv", "4854-t789" ) + private val normalizedDomainParameter = arrayListOf( + "https://api.mindbox.ru", + "api.mindbox.ru/", + "https://api.mindbox.ru/" + ) + private val wrongUuidParameters = arrayListOf( "ларалтка ыфдво", "7659d 79", @@ -115,45 +118,22 @@ class InputParametersUnitTest { } @Test - fun domain_startsWithHttps() { - val errors = SdkValidation.validateConfiguration( - domain = wrongDomainParameter[1], - endpointId = rightEndpointParameter, - previousDeviceUUID = rightUuidParameter, - previousInstallationId = rightUuidParameter - ) - assertEquals(1, errors.size) - assertEquals(SdkValidation.Error.INVALID_FORMAT_DOMAIN, errors[0]) - } - - @Test - fun domain_endsWithSlash() { - val errors = SdkValidation.validateConfiguration( - domain = wrongDomainParameter[2], - endpointId = rightEndpointParameter, - previousDeviceUUID = rightUuidParameter, - previousInstallationId = rightUuidParameter - ) - assertEquals(1, errors.size) - assertEquals(SdkValidation.Error.INVALID_FORMAT_DOMAIN, errors[0]) - } - - @Test - fun domain_startsWithHttpsAndEndsWithSlash() { - val errors = SdkValidation.validateConfiguration( - domain = wrongDomainParameter[3], - endpointId = rightEndpointParameter, - previousDeviceUUID = rightUuidParameter, - previousInstallationId = rightUuidParameter - ) - assertEquals(1, errors.size) - assertEquals(SdkValidation.Error.INVALID_FORMAT_DOMAIN, errors[0]) + fun domain_withSchemeOrTrailingSlash_isNormalized() { + normalizedDomainParameter.forEach { input -> + val errors = SdkValidation.validateConfiguration( + domain = input, + endpointId = rightEndpointParameter, + previousDeviceUUID = rightUuidParameter, + previousInstallationId = rightUuidParameter + ) + assertEquals("Expected 0 errors for '$input'", 0, errors.size) + } } @Test fun domain_InvalidFormat() { val errors4 = SdkValidation.validateConfiguration( - domain = wrongDomainParameter[4], + domain = wrongDomainParameter[1], endpointId = rightEndpointParameter, previousDeviceUUID = rightUuidParameter, previousInstallationId = rightUuidParameter @@ -162,7 +142,7 @@ class InputParametersUnitTest { assertEquals(SdkValidation.Error.INVALID_DOMAIN, errors4[0]) val errors5 = SdkValidation.validateConfiguration( - domain = wrongDomainParameter[5], + domain = wrongDomainParameter[2], endpointId = rightEndpointParameter, previousDeviceUUID = rightUuidParameter, previousInstallationId = rightUuidParameter diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/managers/GatewayManager.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/managers/GatewayManager.kt index e4b269e8..d30c77db 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/managers/GatewayManager.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/managers/GatewayManager.kt @@ -332,7 +332,7 @@ internal class GatewayManager(private val mindboxServiceGenerator: MindboxServic } else { try { JSONObject(body) - } catch (e: JSONException) { + } catch (_: JSONException) { null } } diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/SdkValidationDomainTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/SdkValidationDomainTest.kt new file mode 100644 index 00000000..6695cf78 --- /dev/null +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/SdkValidationDomainTest.kt @@ -0,0 +1,75 @@ +package cloud.mindbox.mobile_sdk + +import org.junit.Assert.assertEquals +import org.junit.Test + +class SdkValidationDomainTest { + + // region extractHost + + @Test + fun `extractHost bare host unchanged`() { + assertEquals("api.mindbox.ru", SdkValidation.extractHost("api.mindbox.ru")) + } + + @Test + fun `extractHost strips https scheme`() { + assertEquals("api.mindbox.ru", SdkValidation.extractHost("https://api.mindbox.ru")) + } + + @Test + fun `extractHost strips http scheme`() { + assertEquals("api.mindbox.ru", SdkValidation.extractHost("http://api.mindbox.ru")) + } + + @Test + fun `extractHost strips trailing slash`() { + assertEquals("api.mindbox.ru", SdkValidation.extractHost("api.mindbox.ru/")) + } + + @Test + fun `extractHost strips https scheme and trailing slash`() { + assertEquals("api.mindbox.ru", SdkValidation.extractHost("https://api.mindbox.ru/")) + } + + @Test + fun `extractHost trims surrounding whitespace`() { + assertEquals("api.mindbox.ru", SdkValidation.extractHost(" api.mindbox.ru ")) + } + + // endregion + + // region toBaseUrl + + @Test + fun `toBaseUrl adds https when no scheme`() { + assertEquals("https://api.mindbox.ru", SdkValidation.toBaseUrl("api.mindbox.ru")) + } + + @Test + fun `toBaseUrl preserves https scheme`() { + assertEquals("https://api.mindbox.ru", SdkValidation.toBaseUrl("https://api.mindbox.ru")) + } + + @Test + fun `toBaseUrl preserves http scheme`() { + assertEquals("http://internal-proxy.com", SdkValidation.toBaseUrl("http://internal-proxy.com")) + } + + @Test + fun `toBaseUrl strips trailing slash when scheme present`() { + assertEquals("https://api.mindbox.ru", SdkValidation.toBaseUrl("https://api.mindbox.ru/")) + } + + @Test + fun `toBaseUrl strips trailing slash when no scheme`() { + assertEquals("https://api.mindbox.ru", SdkValidation.toBaseUrl("api.mindbox.ru/")) + } + + @Test + fun `toBaseUrl preserves http scheme and strips trailing slash`() { + assertEquals("http://proxy.internal", SdkValidation.toBaseUrl("http://proxy.internal/")) + } + + // endregion +} diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/OperationsDomainConfigPolicyTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/OperationsDomainConfigPolicyTest.kt new file mode 100644 index 00000000..bc46a129 --- /dev/null +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/OperationsDomainConfigPolicyTest.kt @@ -0,0 +1,169 @@ +package cloud.mindbox.mobile_sdk.inapp.data.repositories + +import cloud.mindbox.mobile_sdk.SdkValidation +import io.mockk.every +import io.mockk.mockkObject +import io.mockk.unmockkObject +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test + +class OperationsDomainConfigPolicyTest { + + @Before + fun setUp() { + mockkObject(SdkValidation) + every { SdkValidation.isValidDomain(any()) } returns false + every { SdkValidation.isValidDomain(VALID_HOST) } returns true + every { SdkValidation.isValidDomain(VALID_HOST_WITH_SCHEME) } returns true + every { SdkValidation.isValidDomain(ANOTHER_VALID_HOST) } returns true + } + + @After + fun tearDown() { + unmockkObject(SdkValidation) + } + + // region raw null / empty — spec 1.1, 5.7: no rollback + + @Test + fun `raw null stored null returns Keep`() { + val result = OperationsDomainConfigPolicy.action(raw = null, currentlyStored = null) + + assertEquals(OperationsDomainConfigPolicyAction.Keep, result) + } + + @Test + fun `raw null stored has value returns Keep — no rollback per spec`() { + val result = OperationsDomainConfigPolicy.action(raw = null, currentlyStored = VALID_HOST) + + assertEquals(OperationsDomainConfigPolicyAction.Keep, result) + } + + @Test + fun `raw empty stored has value returns Keep — no rollback per spec`() { + val result = OperationsDomainConfigPolicy.action(raw = "", currentlyStored = VALID_HOST) + + assertEquals(OperationsDomainConfigPolicyAction.Keep, result) + } + + @Test + fun `raw blank stored has value returns Keep — no rollback per spec`() { + val result = OperationsDomainConfigPolicy.action(raw = " ", currentlyStored = VALID_HOST) + + assertEquals(OperationsDomainConfigPolicyAction.Keep, result) + } + + // endregion + + // region invalid domain in config — spec 5.6: protect stored value + + @Test + fun `raw invalid domain with stored value returns Keep — protect existing`() { + val result = OperationsDomainConfigPolicy.action( + raw = "not a valid domain!!", + currentlyStored = VALID_HOST + ) + + assertEquals(OperationsDomainConfigPolicyAction.Keep, result) + } + + @Test + fun `raw invalid domain no stored value returns Keep`() { + val result = OperationsDomainConfigPolicy.action( + raw = "not a valid domain!!", + currentlyStored = null + ) + + assertEquals(OperationsDomainConfigPolicyAction.Keep, result) + } + + // endregion + + // region valid new domain — spec 3.1, 3.5 + + @Test + fun `raw valid domain no stored value returns Save`() { + val result = OperationsDomainConfigPolicy.action(raw = VALID_HOST, currentlyStored = null) + + assertEquals(OperationsDomainConfigPolicyAction.Save(VALID_HOST), result) + } + + @Test + fun `raw valid domain same as stored returns Keep`() { + val result = OperationsDomainConfigPolicy.action( + raw = VALID_HOST, + currentlyStored = VALID_HOST + ) + + assertEquals(OperationsDomainConfigPolicyAction.Keep, result) + } + + @Test + fun `raw valid domain different from stored returns Save — URL change on backend`() { + val result = OperationsDomainConfigPolicy.action( + raw = ANOTHER_VALID_HOST, + currentlyStored = VALID_HOST + ) + + assertEquals(OperationsDomainConfigPolicyAction.Save(ANOTHER_VALID_HOST), result) + } + + // endregion + + // region scheme handling — spec 5.3, 5.4: store as-is + + @Test + fun `raw with https scheme stored null returns Save with scheme preserved`() { + val result = OperationsDomainConfigPolicy.action( + raw = VALID_HOST_WITH_SCHEME, + currentlyStored = null + ) + + assertEquals(OperationsDomainConfigPolicyAction.Save(VALID_HOST_WITH_SCHEME), result) + } + + @Test + fun `raw with scheme same as stored returns Keep`() { + val result = OperationsDomainConfigPolicy.action( + raw = VALID_HOST_WITH_SCHEME, + currentlyStored = VALID_HOST_WITH_SCHEME + ) + + assertEquals(OperationsDomainConfigPolicyAction.Keep, result) + } + + // endregion + + // region whitespace trimming + + @Test + fun `raw with leading trailing whitespace is trimmed before comparison`() { + val result = OperationsDomainConfigPolicy.action( + raw = " $VALID_HOST ", + currentlyStored = VALID_HOST + ) + + // trimmed value equals stored → Keep + assertEquals(OperationsDomainConfigPolicyAction.Keep, result) + } + + @Test + fun `raw with whitespace trimmed value is saved`() { + val result = OperationsDomainConfigPolicy.action( + raw = " $VALID_HOST ", + currentlyStored = null + ) + + assertEquals(OperationsDomainConfigPolicyAction.Save(VALID_HOST), result) + } + + // endregion + + private companion object { + const val VALID_HOST = "anonymizer.client.ru" + const val VALID_HOST_WITH_SCHEME = "https://anonymizer.client.ru" + const val ANOTHER_VALID_HOST = "new-anonymizer.client.ru" + } +} diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/managers/GatewayManagerTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/managers/GatewayManagerTest.kt index 4c320827..419c6963 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/managers/GatewayManagerTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/managers/GatewayManagerTest.kt @@ -37,8 +37,175 @@ class GatewayManagerTest { mockkObject(MindboxPreferences) every { MindboxPreferences.deviceUuid } returns "test-device-uuid-123" + every { MindboxPreferences.operationsDomainFromConfig } returns null } + // region resolveOperationsDomain priority chain + + @Test + fun `resolveOperationsDomain returns domain when no operationsDomain configured anywhere`() { + val config = mockConfiguration.copy(domain = "api.mindbox.ru", operationsDomain = null) + + val result = gatewayManager.resolveOperationsDomain(config, operationsDomainFromConfig = null) + + assertEquals("api.mindbox.ru", result) + } + + @Test + fun `resolveOperationsDomain returns operationsDomain from init when config value is null`() { + val config = mockConfiguration.copy(operationsDomain = "anonymizer.client.ru") + + val result = gatewayManager.resolveOperationsDomain(config, operationsDomainFromConfig = null) + + assertEquals("anonymizer.client.ru", result) + } + + @Test + fun `resolveOperationsDomain returns operationsDomainFromConfig over operationsDomain from init`() { + val config = mockConfiguration.copy(operationsDomain = "init-host.com") + + val result = gatewayManager.resolveOperationsDomain(config, operationsDomainFromConfig = "config-host.com") + + assertEquals("config-host.com", result) + } + + @Test + fun `resolveOperationsDomain returns operationsDomainFromConfig when no init value`() { + val config = mockConfiguration.copy(operationsDomain = null) + + val result = gatewayManager.resolveOperationsDomain(config, operationsDomainFromConfig = "config-host.com") + + assertEquals("config-host.com", result) + } + + @Test + fun `resolveOperationsDomain falls through blank operationsDomainFromConfig to init value`() { + val config = mockConfiguration.copy(operationsDomain = "init-host.com") + + val result = gatewayManager.resolveOperationsDomain(config, operationsDomainFromConfig = " ") + + assertEquals("init-host.com", result) + } + + @Test + fun `resolveOperationsDomain falls through blank operationsDomain to domain`() { + val config = mockConfiguration.copy(domain = "api.mindbox.ru", operationsDomain = " ") + + val result = gatewayManager.resolveOperationsDomain(config, operationsDomainFromConfig = null) + + assertEquals("api.mindbox.ru", result) + } + + @Test + fun `resolveOperationsDomain preserves https scheme from init value`() { + val config = mockConfiguration.copy(operationsDomain = "https://proxy.com") + + val result = gatewayManager.resolveOperationsDomain(config, operationsDomainFromConfig = null) + + assertEquals("https://proxy.com", result) + } + + @Test + fun `resolveOperationsDomain preserves http scheme from config value`() { + val config = mockConfiguration.copy(operationsDomain = null) + + val result = gatewayManager.resolveOperationsDomain(config, operationsDomainFromConfig = "http://internal-proxy.com") + + assertEquals("http://internal-proxy.com", result) + } + + // endregion + + // region operationsDomain URL routing + + @Test + fun `operations URL uses domain when no operationsDomain configured anywhere (backward compat)`() { + val config = mockConfiguration.copy(domain = "api.mindbox.ru", operationsDomain = null) + + val url = gatewayManager.getCustomerSegmentationsUrl(config) + + assertTrue("Expected domain fallback", url.startsWith("https://api.mindbox.ru/")) + } + + @Test + fun `operations URL uses operationsDomain from init when SharedPrefs has no value`() { + val config = mockConfiguration.copy(operationsDomain = "anonymizer.client.ru") + + val url = gatewayManager.getCustomerSegmentationsUrl(config) + + assertTrue(url.startsWith("https://anonymizer.client.ru/")) + } + + @Test + fun `operationsDomainFromConfig in SharedPrefs overrides operationsDomain from init`() { + every { MindboxPreferences.operationsDomainFromConfig } returns "config-host.com" + val config = mockConfiguration.copy(operationsDomain = "init-host.com") + + val url = gatewayManager.getCustomerSegmentationsUrl(config) + + assertTrue(url.startsWith("https://config-host.com/")) + } + + @Test + fun `operationsDomainFromConfig in SharedPrefs overrides domain when no init value`() { + every { MindboxPreferences.operationsDomainFromConfig } returns "config-host.com" + val config = mockConfiguration.copy(operationsDomain = null) + + val url = gatewayManager.getCustomerSegmentationsUrl(config) + + assertTrue(url.startsWith("https://config-host.com/")) + } + + @Test + fun `operationsDomain with https scheme preserves scheme in URL`() { + val config = mockConfiguration.copy(operationsDomain = "https://anonymizer.client.ru") + + val url = gatewayManager.getCustomerSegmentationsUrl(config) + + assertTrue(url.startsWith("https://anonymizer.client.ru/")) + } + + @Test + fun `operationsDomain with http scheme uses http scheme`() { + val config = mockConfiguration.copy(operationsDomain = "http://internal-proxy.com") + + val url = gatewayManager.getCustomerSegmentationsUrl(config) + + assertTrue(url.startsWith("http://internal-proxy.com/")) + } + + @Test + fun `logs URL uses operationsDomain from init`() { + val config = mockConfiguration.copy(operationsDomain = "anonymizer.client.ru") + + val url = gatewayManager.getLogsUrl(config) + + assertTrue(url.startsWith("https://anonymizer.client.ru/")) + } + + @Test + fun `product segmentation URL uses operationsDomain from init`() { + val config = mockConfiguration.copy(operationsDomain = "anonymizer.client.ru") + + val url = gatewayManager.getProductSegmentationUrl(config) + + assertTrue(url.startsWith("https://anonymizer.client.ru/")) + } + + @Test + fun `operationsDomain does not affect endpoint ID in URL`() { + val config = mockConfiguration.copy( + endpointId = "test-endpoint-id", + operationsDomain = "anonymizer.client.ru" + ) + + val url = gatewayManager.getCustomerSegmentationsUrl(config) + + assertTrue(url.contains("endpointId=test-endpoint-id")) + } + + // endregion + @Test fun `getCustomerSegmentationsUrl should return correct URL with endpointId and deviceUUID`() { val customConfig = mockConfiguration.copy( diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/managers/MobileConfigSettingsManagerTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/managers/MobileConfigSettingsManagerTest.kt index 5a22a40b..4fbcfde9 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/managers/MobileConfigSettingsManagerTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/managers/MobileConfigSettingsManagerTest.kt @@ -143,7 +143,7 @@ class MobileConfigSettingsManagerImplTest { @Test fun `checkPushTokenKeepalive not sends when SlidingExpiration is null`() { every { MindboxPreferences.lastInfoUpdateTime } returns now - val config = InAppConfigResponse(null, null, SettingsDto(null, null, null, null, null), null) + val config = InAppConfigResponse(null, null, SettingsDto(null, null, null, null, null, null), null) mobileConfigSettingsManager.checkPushTokenKeepalive(config) verify(exactly = 0) { MindboxEventManager.appKeepalive(any(), any()) } From d797449e6f0b7b9879c8376b753d8a3e33d19a95 Mon Sep 17 00:00:00 2001 From: Egor Kitselyuk Date: Mon, 27 Apr 2026 17:29:42 +0300 Subject: [PATCH 09/15] MOBILE-129: Follow code review --- .../repositories/OperationsDomainConfigPolicy.kt | 4 +++- .../mobile_sdk/repository/MindboxPreferences.kt | 5 ----- .../OperationsDomainConfigPolicyTest.kt | 14 +++++++------- .../mobile_sdk/managers/GatewayManagerTest.kt | 7 +++++++ 4 files changed, 17 insertions(+), 13 deletions(-) diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/OperationsDomainConfigPolicy.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/OperationsDomainConfigPolicy.kt index b7efc508..a03e4a01 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/OperationsDomainConfigPolicy.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/OperationsDomainConfigPolicy.kt @@ -14,7 +14,9 @@ internal object OperationsDomainConfigPolicy { fun action(raw: String?, currentlyStored: String?): OperationsDomainConfigPolicyAction { val value = raw?.trim()?.takeIf { it.isNotBlank() } - ?: return OperationsDomainConfigPolicyAction.Keep + ?: return currentlyStored?.let { + OperationsDomainConfigPolicyAction.Clear + } ?: OperationsDomainConfigPolicyAction.Keep if (!SdkValidation.isValidDomain(value)) return OperationsDomainConfigPolicyAction.Keep diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/repository/MindboxPreferences.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/repository/MindboxPreferences.kt index e8b631d8..edb7a6d5 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/repository/MindboxPreferences.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/repository/MindboxPreferences.kt @@ -278,11 +278,6 @@ internal object MindboxPreferences { } } - /** - * Operations domain received from the remote mobile config (settings.baseAddresses.operations). - * Takes priority over [cloud.mindbox.mobile_sdk.MindboxConfiguration.Builder.operationsDomain] set at init time. - * Supports rollback: cleared when the remote config omits the field and a value was stored. - */ var operationsDomainFromConfig: String? get() = loggingRunCatching(defaultValue = null) { SharedPreferencesManager.getString(KEY_OPERATIONS_DOMAIN_FROM_CONFIG) diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/OperationsDomainConfigPolicyTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/OperationsDomainConfigPolicyTest.kt index bc46a129..8db8e830 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/OperationsDomainConfigPolicyTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/OperationsDomainConfigPolicyTest.kt @@ -25,7 +25,7 @@ class OperationsDomainConfigPolicyTest { unmockkObject(SdkValidation) } - // region raw null / empty — spec 1.1, 5.7: no rollback + // region raw null / empty — backend omitted value: clear if stored, keep if nothing to clear @Test fun `raw null stored null returns Keep`() { @@ -35,24 +35,24 @@ class OperationsDomainConfigPolicyTest { } @Test - fun `raw null stored has value returns Keep — no rollback per spec`() { + fun `raw null stored has value returns Clear`() { val result = OperationsDomainConfigPolicy.action(raw = null, currentlyStored = VALID_HOST) - assertEquals(OperationsDomainConfigPolicyAction.Keep, result) + assertEquals(OperationsDomainConfigPolicyAction.Clear, result) } @Test - fun `raw empty stored has value returns Keep — no rollback per spec`() { + fun `raw empty stored has value returns Clear`() { val result = OperationsDomainConfigPolicy.action(raw = "", currentlyStored = VALID_HOST) - assertEquals(OperationsDomainConfigPolicyAction.Keep, result) + assertEquals(OperationsDomainConfigPolicyAction.Clear, result) } @Test - fun `raw blank stored has value returns Keep — no rollback per spec`() { + fun `raw blank stored has value returns Clear`() { val result = OperationsDomainConfigPolicy.action(raw = " ", currentlyStored = VALID_HOST) - assertEquals(OperationsDomainConfigPolicyAction.Keep, result) + assertEquals(OperationsDomainConfigPolicyAction.Clear, result) } // endregion diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/managers/GatewayManagerTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/managers/GatewayManagerTest.kt index 419c6963..b92fc3d2 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/managers/GatewayManagerTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/managers/GatewayManagerTest.kt @@ -6,6 +6,8 @@ import cloud.mindbox.mobile_sdk.repository.MindboxPreferences import io.mockk.every import io.mockk.mockk import io.mockk.mockkObject +import io.mockk.unmockkObject +import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Before @@ -40,6 +42,11 @@ class GatewayManagerTest { every { MindboxPreferences.operationsDomainFromConfig } returns null } + @After + fun onTestEnd() { + unmockkObject(MindboxPreferences) + } + // region resolveOperationsDomain priority chain @Test From a0811cbf03de940e1f235f938e324cb9a80f5354 Mon Sep 17 00:00:00 2001 From: Egor Kitselyuk Date: Tue, 28 Apr 2026 12:38:09 +0300 Subject: [PATCH 10/15] MOBILE-129: Add tests for domain validations --- .../cloud/mindbox/mobile_sdk/SdkValidation.kt | 10 ++-- .../MobileConfigRepositoryImpl.kt | 2 +- .../OperationsDomainConfigPolicy.kt | 28 ++++++----- .../mobile_sdk/SdkValidationDomainTest.kt | 44 +++++++++++++++++ .../OperationsDomainConfigPolicyTest.kt | 49 ++++++++++++++----- 5 files changed, 102 insertions(+), 31 deletions(-) diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/SdkValidation.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/SdkValidation.kt index 6f0e91c7..6fe73332 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/SdkValidation.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/SdkValidation.kt @@ -35,12 +35,14 @@ internal object SdkValidation { * "api.mindbox.ru" → "https://api.mindbox.ru" * "http://proxy.example.com" → "http://proxy.example.com" */ - fun toBaseUrl(hostOrUrl: String): String = - if (hostOrUrl.startsWith("http://") || hostOrUrl.startsWith("https://")) { - hostOrUrl.trimEnd('/') + fun toBaseUrl(hostOrUrl: String): String { + val trimmed = hostOrUrl.trim() + return if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) { + trimmed.trimEnd('/') } else { - "https://${hostOrUrl.trimEnd('/')}" + "https://${trimmed.trimEnd('/')}" } + } /** * Returns true if [domain] is a valid domain host, accepting optional http:// or https:// prefix diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/MobileConfigRepositoryImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/MobileConfigRepositoryImpl.kt index 26812e53..42abb68d 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/MobileConfigRepositoryImpl.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/MobileConfigRepositoryImpl.kt @@ -209,7 +209,7 @@ internal class MobileConfigRepositoryImpl( private fun persistOperationsDomain(config: InAppConfigResponse) { val raw = config.settings?.baseAddresses?.operations val stored = MindboxPreferences.operationsDomainFromConfig - when (val action = OperationsDomainConfigPolicy.action(raw, stored)) { + when (val action = operationsDomainConfigPolicyAction(raw, stored)) { is OperationsDomainConfigPolicyAction.Save -> { mindboxLogD("operationsDomain: saving '${action.value}'") MindboxPreferences.operationsDomainFromConfig = action.value diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/OperationsDomainConfigPolicy.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/OperationsDomainConfigPolicy.kt index a03e4a01..87d55f28 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/OperationsDomainConfigPolicy.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/OperationsDomainConfigPolicy.kt @@ -10,20 +10,22 @@ internal sealed class OperationsDomainConfigPolicyAction { object Keep : OperationsDomainConfigPolicyAction() } -internal object OperationsDomainConfigPolicy { - - fun action(raw: String?, currentlyStored: String?): OperationsDomainConfigPolicyAction { - val value = raw?.trim()?.takeIf { it.isNotBlank() } - ?: return currentlyStored?.let { - OperationsDomainConfigPolicyAction.Clear - } ?: OperationsDomainConfigPolicyAction.Keep - - if (!SdkValidation.isValidDomain(value)) return OperationsDomainConfigPolicyAction.Keep - - return if (value == currentlyStored) { - OperationsDomainConfigPolicyAction.Keep +internal fun operationsDomainConfigPolicyAction( + raw: String?, + currentlyStored: String?, +): OperationsDomainConfigPolicyAction { + val value = raw?.trim()?.takeIf { it.isNotBlank() } + ?: return if (currentlyStored != null) { + OperationsDomainConfigPolicyAction.Clear } else { - OperationsDomainConfigPolicyAction.Save(value) + OperationsDomainConfigPolicyAction.Keep } + + if (!SdkValidation.isValidDomain(value)) return OperationsDomainConfigPolicyAction.Keep + + return if (value == currentlyStored) { + OperationsDomainConfigPolicyAction.Keep + } else { + OperationsDomainConfigPolicyAction.Save(value) } } diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/SdkValidationDomainTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/SdkValidationDomainTest.kt index 6695cf78..1761acb9 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/SdkValidationDomainTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/SdkValidationDomainTest.kt @@ -71,5 +71,49 @@ class SdkValidationDomainTest { assertEquals("http://proxy.internal", SdkValidation.toBaseUrl("http://proxy.internal/")) } + @Test + fun `toBaseUrl trims surrounding whitespace before adding scheme`() { + assertEquals("https://api.mindbox.ru", SdkValidation.toBaseUrl(" api.mindbox.ru ")) + } + + @Test + fun `toBaseUrl trims surrounding whitespace when scheme present`() { + assertEquals("https://api.mindbox.ru", SdkValidation.toBaseUrl(" https://api.mindbox.ru ")) + } + + // endregion + + // region isValidDomain + + @Test + fun `isValidDomain accepts bare host`() { + assertEquals(true, SdkValidation.isValidDomain("api.mindbox.ru")) + } + + @Test + fun `isValidDomain accepts https scheme`() { + assertEquals(true, SdkValidation.isValidDomain("https://api.mindbox.ru")) + } + + @Test + fun `isValidDomain accepts https scheme with trailing slash`() { + assertEquals(true, SdkValidation.isValidDomain("https://api.mindbox.ru/")) + } + + @Test + fun `isValidDomain accepts bare host with trailing slash`() { + assertEquals(true, SdkValidation.isValidDomain("api.mindbox.ru/")) + } + + @Test + fun `isValidDomain rejects blank string`() { + assertEquals(false, SdkValidation.isValidDomain("")) + } + + @Test + fun `isValidDomain rejects string with spaces`() { + assertEquals(false, SdkValidation.isValidDomain("not a domain")) + } + // endregion } diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/OperationsDomainConfigPolicyTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/OperationsDomainConfigPolicyTest.kt index 8db8e830..5fbb7df6 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/OperationsDomainConfigPolicyTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/OperationsDomainConfigPolicyTest.kt @@ -17,6 +17,7 @@ class OperationsDomainConfigPolicyTest { every { SdkValidation.isValidDomain(any()) } returns false every { SdkValidation.isValidDomain(VALID_HOST) } returns true every { SdkValidation.isValidDomain(VALID_HOST_WITH_SCHEME) } returns true + every { SdkValidation.isValidDomain(VALID_HOST_WITH_TRAILING_SLASH) } returns true every { SdkValidation.isValidDomain(ANOTHER_VALID_HOST) } returns true } @@ -29,28 +30,28 @@ class OperationsDomainConfigPolicyTest { @Test fun `raw null stored null returns Keep`() { - val result = OperationsDomainConfigPolicy.action(raw = null, currentlyStored = null) + val result = operationsDomainConfigPolicyAction(raw = null, currentlyStored = null) assertEquals(OperationsDomainConfigPolicyAction.Keep, result) } @Test fun `raw null stored has value returns Clear`() { - val result = OperationsDomainConfigPolicy.action(raw = null, currentlyStored = VALID_HOST) + val result = operationsDomainConfigPolicyAction(raw = null, currentlyStored = VALID_HOST) assertEquals(OperationsDomainConfigPolicyAction.Clear, result) } @Test fun `raw empty stored has value returns Clear`() { - val result = OperationsDomainConfigPolicy.action(raw = "", currentlyStored = VALID_HOST) + val result = operationsDomainConfigPolicyAction(raw = "", currentlyStored = VALID_HOST) assertEquals(OperationsDomainConfigPolicyAction.Clear, result) } @Test fun `raw blank stored has value returns Clear`() { - val result = OperationsDomainConfigPolicy.action(raw = " ", currentlyStored = VALID_HOST) + val result = operationsDomainConfigPolicyAction(raw = " ", currentlyStored = VALID_HOST) assertEquals(OperationsDomainConfigPolicyAction.Clear, result) } @@ -61,7 +62,7 @@ class OperationsDomainConfigPolicyTest { @Test fun `raw invalid domain with stored value returns Keep — protect existing`() { - val result = OperationsDomainConfigPolicy.action( + val result = operationsDomainConfigPolicyAction( raw = "not a valid domain!!", currentlyStored = VALID_HOST ) @@ -71,7 +72,7 @@ class OperationsDomainConfigPolicyTest { @Test fun `raw invalid domain no stored value returns Keep`() { - val result = OperationsDomainConfigPolicy.action( + val result = operationsDomainConfigPolicyAction( raw = "not a valid domain!!", currentlyStored = null ) @@ -85,14 +86,14 @@ class OperationsDomainConfigPolicyTest { @Test fun `raw valid domain no stored value returns Save`() { - val result = OperationsDomainConfigPolicy.action(raw = VALID_HOST, currentlyStored = null) + val result = operationsDomainConfigPolicyAction(raw = VALID_HOST, currentlyStored = null) assertEquals(OperationsDomainConfigPolicyAction.Save(VALID_HOST), result) } @Test fun `raw valid domain same as stored returns Keep`() { - val result = OperationsDomainConfigPolicy.action( + val result = operationsDomainConfigPolicyAction( raw = VALID_HOST, currentlyStored = VALID_HOST ) @@ -102,7 +103,7 @@ class OperationsDomainConfigPolicyTest { @Test fun `raw valid domain different from stored returns Save — URL change on backend`() { - val result = OperationsDomainConfigPolicy.action( + val result = operationsDomainConfigPolicyAction( raw = ANOTHER_VALID_HOST, currentlyStored = VALID_HOST ) @@ -116,7 +117,7 @@ class OperationsDomainConfigPolicyTest { @Test fun `raw with https scheme stored null returns Save with scheme preserved`() { - val result = OperationsDomainConfigPolicy.action( + val result = operationsDomainConfigPolicyAction( raw = VALID_HOST_WITH_SCHEME, currentlyStored = null ) @@ -126,7 +127,7 @@ class OperationsDomainConfigPolicyTest { @Test fun `raw with scheme same as stored returns Keep`() { - val result = OperationsDomainConfigPolicy.action( + val result = operationsDomainConfigPolicyAction( raw = VALID_HOST_WITH_SCHEME, currentlyStored = VALID_HOST_WITH_SCHEME ) @@ -134,13 +135,34 @@ class OperationsDomainConfigPolicyTest { assertEquals(OperationsDomainConfigPolicyAction.Keep, result) } + @Test + fun `raw with trailing slash is saved as-is`() { + val result = operationsDomainConfigPolicyAction( + raw = VALID_HOST_WITH_TRAILING_SLASH, + currentlyStored = null + ) + + // value is stored as-is; toBaseUrl() strips the slash when building the request URL + assertEquals(OperationsDomainConfigPolicyAction.Save(VALID_HOST_WITH_TRAILING_SLASH), result) + } + + @Test + fun `raw with trailing slash same as stored returns Keep`() { + val result = operationsDomainConfigPolicyAction( + raw = VALID_HOST_WITH_TRAILING_SLASH, + currentlyStored = VALID_HOST_WITH_TRAILING_SLASH + ) + + assertEquals(OperationsDomainConfigPolicyAction.Keep, result) + } + // endregion // region whitespace trimming @Test fun `raw with leading trailing whitespace is trimmed before comparison`() { - val result = OperationsDomainConfigPolicy.action( + val result = operationsDomainConfigPolicyAction( raw = " $VALID_HOST ", currentlyStored = VALID_HOST ) @@ -151,7 +173,7 @@ class OperationsDomainConfigPolicyTest { @Test fun `raw with whitespace trimmed value is saved`() { - val result = OperationsDomainConfigPolicy.action( + val result = operationsDomainConfigPolicyAction( raw = " $VALID_HOST ", currentlyStored = null ) @@ -164,6 +186,7 @@ class OperationsDomainConfigPolicyTest { private companion object { const val VALID_HOST = "anonymizer.client.ru" const val VALID_HOST_WITH_SCHEME = "https://anonymizer.client.ru" + const val VALID_HOST_WITH_TRAILING_SLASH = "https://anonymizer.client.ru/" const val ANOTHER_VALID_HOST = "new-anonymizer.client.ru" } } From c53f506624c939d0fd4837c7c3dc96a4ac7c3080 Mon Sep 17 00:00:00 2001 From: Egor Kitselyuk Date: Tue, 28 Apr 2026 15:17:11 +0300 Subject: [PATCH 11/15] MOBILE-129: Add critical error for invalid operationDomain --- sdk/src/main/java/cloud/mindbox/mobile_sdk/SdkValidation.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/SdkValidation.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/SdkValidation.kt index 6fe73332..ed2f7f34 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/SdkValidation.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/SdkValidation.kt @@ -13,7 +13,7 @@ internal object SdkValidation { EMPTY_ENDPOINT(true, "Endpoint must not be empty"), INVALID_DEVICE_ID(false, "Invalid previous device UUID format"), INVALID_INSTALLATION_ID(false, "Invalid UUID format of previous installationId"), - INVALID_OPERATIONS_DOMAIN(false, "The operationsDomain is not valid, it will be ignored"); + INVALID_OPERATIONS_DOMAIN(true, "The operationsDomain is not valid"); override fun toString() = "$name(critical=$critical, message=$message)" } From 7c5b3df91e30d7988d413fcbcee67980101fc5b4 Mon Sep 17 00:00:00 2001 From: Egor Kitselyuk Date: Fri, 8 May 2026 11:07:23 +0300 Subject: [PATCH 12/15] MOBILE-173: Add anntations SerializedName for WebviewBridge --- sdk/consumer-rules.pro | 5 +-- .../inapp/presentation/view/WebViewAction.kt | 36 +++++++++---------- .../view/WebViewInappViewHolder.kt | 2 ++ 3 files changed, 21 insertions(+), 22 deletions(-) diff --git a/sdk/consumer-rules.pro b/sdk/consumer-rules.pro index 9d3c19fb..80767f1d 100644 --- a/sdk/consumer-rules.pro +++ b/sdk/consumer-rules.pro @@ -1,9 +1,6 @@ # Keep model classes -keepclassmembers class cloud.mindbox.mobile_sdk.models** { *; } --keepclassmembers enum cloud.mindbox.mobile_sdk.models** { *; } -keep class cloud.mindbox.mobile_sdk.MindboxConfiguration { *; } -keep class cloud.mindbox.mobile_sdk.pushes.PushAction { *; } --keep class cloud.mindbox.mobile_sdk.inapp.data** { *; } +-keepclassmembers class cloud.mindbox.mobile_sdk.inapp.data.dto.** { *; } -keep class cloud.mindbox.mobile_sdk.inapp.domain.models** { *; } - --keep public class * extends android.preference.Preference \ No newline at end of file diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewAction.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewAction.kt index ce539766..f44440c6 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewAction.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewAction.kt @@ -84,30 +84,30 @@ public sealed class BridgeMessage { public abstract val timestamp: Long public data class Request( - override val version: Int, - override val action: WebViewAction, - override val payload: String?, - override val id: String, - override val timestamp: Long, - override val type: String = TYPE_REQUEST, + @SerializedName("version") override val version: Int, + @SerializedName("action") override val action: WebViewAction, + @SerializedName("payload") override val payload: String?, + @SerializedName("id") override val id: String, + @SerializedName("timestamp") override val timestamp: Long, + @SerializedName("type") override val type: String = TYPE_REQUEST, ) : BridgeMessage() public data class Response( - override val version: Int, - override val action: WebViewAction, - override val payload: String?, - override val id: String, - override val timestamp: Long, - override val type: String = TYPE_RESPONSE, + @SerializedName("version") override val version: Int, + @SerializedName("action") override val action: WebViewAction, + @SerializedName("payload") override val payload: String?, + @SerializedName("id") override val id: String, + @SerializedName("timestamp") override val timestamp: Long, + @SerializedName("type") override val type: String = TYPE_RESPONSE, ) : BridgeMessage() public data class Error( - override val version: Int, - override val action: WebViewAction, - override val payload: String?, - override val id: String, - override val timestamp: Long, - override val type: String = TYPE_ERROR, + @SerializedName("version") override val version: Int, + @SerializedName("action") override val action: WebViewAction, + @SerializedName("payload") override val payload: String?, + @SerializedName("id") override val id: String, + @SerializedName("timestamp") override val timestamp: Long, + @SerializedName("type") override val type: String = TYPE_ERROR, ) : BridgeMessage() public companion object { diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt index 41d8ba2e..76c5ccca 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt @@ -782,10 +782,12 @@ internal class WebViewInAppViewHolder( } private data class NavigationInterceptedPayload( + @SerializedName("url") val url: String ) private data class ErrorPayload( + @SerializedName("error") val error: String ) From 8134b01dfd754da30f9cea2e92dcbeec2be9c3cb Mon Sep 17 00:00:00 2001 From: Egor Kitselyuk Date: Fri, 8 May 2026 12:12:57 +0300 Subject: [PATCH 13/15] Add kover --- .github/workflows/lint_unitTests_build.yml | 28 ++++++++++++++++++++-- build.gradle | 13 ++++++++++ gradle/libs.versions.toml | 5 +++- modulesCommon.gradle | 1 + 4 files changed, 44 insertions(+), 3 deletions(-) diff --git a/.github/workflows/lint_unitTests_build.yml b/.github/workflows/lint_unitTests_build.yml index 1cb614ca..73064f50 100644 --- a/.github/workflows/lint_unitTests_build.yml +++ b/.github/workflows/lint_unitTests_build.yml @@ -11,6 +11,9 @@ on: - reopened - synchronize +permissions: + contents: read + jobs: lint: runs-on: ubuntu-latest @@ -64,13 +67,34 @@ jobs: - name: Setup Android SDK uses: android-actions/setup-android@v2 - - name: unit tests - run: ./gradlew --no-daemon --stacktrace testDebugUnitTest + - name: unit tests with coverage + run: ./gradlew --no-daemon --stacktrace testDebugUnitTest koverHtmlReport - name: test report uses: asadmansr/android-test-report-action@v1.2.0 if: ${{ always() }} + - name: upload coverage report + uses: actions/upload-pages-artifact@v3 + if: github.ref == 'refs/heads/develop' + with: + path: build/reports/kover/html + + deploy-coverage: + runs-on: ubuntu-latest + needs: unit + if: github.ref == 'refs/heads/develop' + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deploy.outputs.page_url }} + steps: + - name: deploy to GitHub Pages + id: deploy + uses: actions/deploy-pages@v4 + build: runs-on: ubuntu-latest steps: diff --git a/build.gradle b/build.gradle index 1159082e..55faa13a 100644 --- a/build.gradle +++ b/build.gradle @@ -22,6 +22,19 @@ allprojects { } } +apply plugin: 'org.jetbrains.kotlinx.kover' + +dependencies { + kover(project(':sdk')) + kover(project(':mindbox-firebase')) + kover(project(':mindbox-huawei')) + kover(project(':mindbox-rustore')) + kover(project(':mindbox-firebase-starter')) + kover(project(':mindbox-huawei-starter')) + kover(project(':mindbox-rustore-starter')) + kover(project(':mindbox-sdk-starter-core')) +} + tasks.register('clean', Delete) { delete rootProject.getLayout().getBuildDirectory() } \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1ce4ed8e..926a903d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -39,6 +39,7 @@ agcp = "1.9.1.300" ktlint-plugin = "12.1.1" ksp = "1.9.22-1.0.17" maven_publish = "0.32.0" +kover = "0.8.3" pushclient = "7.2.0" @@ -51,6 +52,7 @@ buildscript-plugins = [ "ktlint_gradle_plugin", "ksp_gradle_plugin", "maven_publish_plugin", + "kover_gradle_plugin", ] test = [ @@ -116,4 +118,5 @@ google_services = { module = "com.google.gms:google-services", version.ref = "go agcp = { module = "com.huawei.agconnect:agcp", version.ref = "agcp" } ktlint_gradle_plugin = { module = "org.jlleitschuh.gradle:ktlint-gradle", version.ref = "ktlint-plugin" } ksp_gradle_plugin = { module = "com.google.devtools.ksp:symbol-processing-gradle-plugin", version.ref = "ksp" } -maven_publish_plugin = { module = "com.vanniktech:gradle-maven-publish-plugin", version.ref = "maven_publish" } \ No newline at end of file +maven_publish_plugin = { module = "com.vanniktech:gradle-maven-publish-plugin", version.ref = "maven_publish" } +kover_gradle_plugin = { module = "org.jetbrains.kotlinx:kover-gradle-plugin", version.ref = "kover" } \ No newline at end of file diff --git a/modulesCommon.gradle b/modulesCommon.gradle index 3d607614..e1b436a4 100644 --- a/modulesCommon.gradle +++ b/modulesCommon.gradle @@ -3,6 +3,7 @@ apply plugin: 'kotlin-android' apply plugin: 'signing' apply plugin: 'org.jlleitschuh.gradle.ktlint' apply plugin: 'com.vanniktech.maven.publish' +apply plugin: 'org.jetbrains.kotlinx.kover' group = 'com.github.mindbox-cloud' From d4e5b6c240617a1641656124ba9db2e052828aa4 Mon Sep 17 00:00:00 2001 From: Egor Kitselyuk Date: Fri, 8 May 2026 11:55:35 +0300 Subject: [PATCH 14/15] MOBILE-173: Add test for gson serialization --- .../models/MindboxErrorAdapterTest.kt | 260 ++++++++++++++++++ .../adapters/CustomerFieldsAdapterTest.kt | 152 ++++++++++ .../operation/adapters/DateOnlyAdapterTest.kt | 144 ++++++++++ .../operation/adapters/DateTimeAdapterTest.kt | 161 +++++++++++ .../operation/adapters/IdsAdapterTest.kt | 132 +++++++++ .../ProductListResponseAdapterTest.kt | 146 ++++++++++ 6 files changed, 995 insertions(+) create mode 100644 sdk/src/test/java/cloud/mindbox/mobile_sdk/models/MindboxErrorAdapterTest.kt create mode 100644 sdk/src/test/java/cloud/mindbox/mobile_sdk/models/operation/adapters/CustomerFieldsAdapterTest.kt create mode 100644 sdk/src/test/java/cloud/mindbox/mobile_sdk/models/operation/adapters/DateOnlyAdapterTest.kt create mode 100644 sdk/src/test/java/cloud/mindbox/mobile_sdk/models/operation/adapters/DateTimeAdapterTest.kt create mode 100644 sdk/src/test/java/cloud/mindbox/mobile_sdk/models/operation/adapters/IdsAdapterTest.kt create mode 100644 sdk/src/test/java/cloud/mindbox/mobile_sdk/models/operation/adapters/ProductListResponseAdapterTest.kt diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/models/MindboxErrorAdapterTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/models/MindboxErrorAdapterTest.kt new file mode 100644 index 00000000..39689ad5 --- /dev/null +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/models/MindboxErrorAdapterTest.kt @@ -0,0 +1,260 @@ +package cloud.mindbox.mobile_sdk.models + +import com.google.gson.Gson +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Tests for [MindboxErrorAdapter] via the public [MindboxError.toJson] API. + * + * The adapter handles two directions: + * - write (toJson) — used by SDK clients to log/pass errors around — fully implemented. + * - read (fromJson) — parsing is effectively non-functional in the current implementation + * (the read() method reads the JSON key name instead of key value, causing it to always + * fall through to `else -> null`). Tests below document this known behavior. + */ +class MindboxErrorAdapterTest { + + // MindboxError subtypes have @JsonAdapter(MindboxErrorAdapter::class) + private val gson = Gson() + + // region write / toJson — Validation + + @Test + fun `toJson Validation - contains type MindboxError`() { + val error = MindboxError.Validation( + statusCode = 200, + status = "ValidationError", + validationMessages = emptyList(), + ) + val json = error.toJson() + assertTrue(json.contains(""""type":"MindboxError"""")) + } + + @Test + fun `toJson Validation - contains statusCode`() { + val error = MindboxError.Validation(200, "ValidationError", emptyList()) + val json = error.toJson() + assertTrue(json.contains(""""statusCode":200""")) + } + + @Test + fun `toJson Validation - contains status`() { + val error = MindboxError.Validation(200, "ValidationError", emptyList()) + val json = error.toJson() + assertTrue(json.contains(""""status":"ValidationError"""")) + } + + @Test + fun `toJson Validation - contains empty validationMessages array`() { + val error = MindboxError.Validation(200, "ValidationError", emptyList()) + val json = error.toJson() + assertTrue(json.contains(""""validationMessages":[]""")) + } + + @Test + fun `toJson Validation - contains validationMessages with entries`() { + val error = MindboxError.Validation( + statusCode = 200, + status = "ValidationError", + validationMessages = listOf( + ValidationMessage(message = "field required", location = "email"), + ), + ) + val json = error.toJson() + assertTrue(json.contains(""""message":"field required"""")) + assertTrue(json.contains(""""location":"email"""")) + } + + @Test + fun `toJson Validation - full JSON structure`() { + val error = MindboxError.Validation(200, "Ok", emptyList()) + val json = error.toJson() + assertEquals( + """{"type":"MindboxError","data":{"statusCode":200,"status":"Ok","validationMessages":[]}}""", + json, + ) + } + + // endregion + + // region write / toJson — Protocol + + @Test + fun `toJson Protocol - contains type MindboxError`() { + val error = MindboxError.Protocol( + statusCode = 400, + status = "Error", + errorMessage = "Bad request", + errorId = "err-1", + httpStatusCode = 400, + ) + val json = error.toJson() + assertTrue(json.contains(""""type":"MindboxError"""")) + } + + @Test + fun `toJson Protocol - full JSON structure`() { + val error = MindboxError.Protocol(400, "Error", "Bad request", "err-1", 400) + val json = error.toJson() + assertEquals( + """{"type":"MindboxError","data":{"statusCode":400,"status":"Error","errorMessage":"Bad request","errorId":"err-1","httpStatusCode":400}}""", + json, + ) + } + + @Test + fun `toJson Protocol - null optional fields omitted from JSON`() { + // GSON's nullValue() silently skips a name+value pair when serializeNulls = false + // (the default). MindboxErrorAdapter does not override this, so null fields are + // absent from the output — not present as "null". This is the current behavior. + val error = MindboxError.Protocol(403, "Forbidden", null, null, null) + val json = error.toJson() + assertFalse("null errorMessage should be omitted", json.contains("errorMessage")) + assertFalse("null errorId should be omitted", json.contains("errorId")) + assertFalse("null httpStatusCode should be omitted", json.contains("httpStatusCode")) + } + + // endregion + + // region write / toJson — InternalServer + + @Test + fun `toJson InternalServer - contains type MindboxError`() { + val error = MindboxError.InternalServer(500, "ServerError", "Internal error", "id-1", 500) + val json = error.toJson() + assertTrue(json.contains(""""type":"MindboxError"""")) + } + + @Test + fun `toJson InternalServer - full JSON structure`() { + val error = MindboxError.InternalServer(500, "ServerError", "Internal error", "id-1", 500) + val json = error.toJson() + assertEquals( + """{"type":"MindboxError","data":{"statusCode":500,"status":"ServerError","errorMessage":"Internal error","errorId":"id-1","httpStatusCode":500}}""", + json, + ) + } + + // endregion + + // region write / toJson — UnknownServer + + @Test + fun `toJson UnknownServer - contains type NetworkError`() { + val error = MindboxError.UnknownServer() + val json = error.toJson() + assertTrue(json.contains(""""type":"NetworkError"""")) + } + + @Test + fun `toJson UnknownServer - default constructor full JSON`() { + val error = MindboxError.UnknownServer() + val json = error.toJson() + // Default constructor sets errorMessage = "Cannot reach server", all else null + assertTrue(json.contains(""""errorMessage":"Cannot reach server"""")) + } + + @Test + fun `toJson UnknownServer - with all fields`() { + val error = MindboxError.UnknownServer(503, "Unavailable", "Service down", "id-2", 503) + val json = error.toJson() + assertEquals( + """{"type":"NetworkError","data":{"statusCode":503,"status":"Unavailable","errorMessage":"Service down","errorId":"id-2","httpStatusCode":503}}""", + json, + ) + } + + // endregion + + // region write / toJson — Unknown + + @Test + fun `toJson Unknown - contains type InternalError`() { + val error = MindboxError.Unknown() + val json = error.toJson() + assertTrue(json.contains(""""type":"InternalError"""")) + } + + @Test + fun `toJson Unknown - null throwable produces empty data object`() { + // Both errorName and errorMessage are null → both name+null pairs are silently + // dropped by GSON (serializeNulls = false). The data object is empty. + val error = MindboxError.Unknown(throwable = null) + val json = error.toJson() + assertFalse("null errorName should be omitted", json.contains("errorName")) + assertFalse("null errorMessage should be omitted", json.contains("errorMessage")) + assertTrue("data object should be present but empty", json.contains(""""data":{}""")) + } + + @Test + fun `toJson Unknown - throwable class name and message included`() { + val throwable = RuntimeException("something went wrong") + val error = MindboxError.Unknown(throwable) + val json = error.toJson() + assertTrue(json.contains("RuntimeException")) + assertTrue(json.contains("something went wrong")) + } + + // endregion + + // region read / fromJson — current behavior documentation + + @Test + fun `fromJson Validation - current behavior returns null (read is not implemented)`() { + // MindboxErrorAdapter.read() calls nextString() after beginObject() which reads + // the key name "type" instead of its value. None of the when-branches match "type", + // so the method always returns null. This test documents that known limitation so + // that a future fix or migration will be noticed immediately. + val json = MindboxError.Validation(200, "Ok", emptyList()).toJson() + val result = gson.fromJson(json, MindboxError.Validation::class.java) + assertNull(result) + } + + @Test + fun `fromJson Protocol - current behavior returns null`() { + val json = MindboxError.Protocol(400, "Error", null, null, null).toJson() + val result = gson.fromJson(json, MindboxError.Protocol::class.java) + assertNull(result) + } + + @Test + fun `fromJson UnknownServer - current behavior returns null`() { + val json = MindboxError.UnknownServer().toJson() + val result = gson.fromJson(json, MindboxError.UnknownServer::class.java) + assertNull(result) + } + + // endregion + + // region toJson output is valid JSON + + @Test + fun `toJson output is parseable by Gson as JsonObject`() { + listOf( + MindboxError.Validation(200, "Ok", emptyList()), + MindboxError.Protocol(400, "Error", null, null, null), + MindboxError.InternalServer(500, "Err", null, null, null), + MindboxError.UnknownServer(), + MindboxError.Unknown(), + ).forEach { error -> + val json = error.toJson() + val parsed = gson.fromJson(json, com.google.gson.JsonObject::class.java) + assertNotNull("toJson() should produce valid JSON for ${error::class.simpleName}", parsed) + assertTrue( + "${error::class.simpleName} JSON should have 'type' key", + parsed.has("type"), + ) + assertTrue( + "${error::class.simpleName} JSON should have 'data' key", + parsed.has("data"), + ) + } + } + + // endregion +} diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/models/operation/adapters/CustomerFieldsAdapterTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/models/operation/adapters/CustomerFieldsAdapterTest.kt new file mode 100644 index 00000000..756fdf7c --- /dev/null +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/models/operation/adapters/CustomerFieldsAdapterTest.kt @@ -0,0 +1,152 @@ +package cloud.mindbox.mobile_sdk.models.operation.adapters + +import cloud.mindbox.mobile_sdk.models.operation.CustomFields +import com.google.gson.Gson +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class CustomerFieldsAdapterTest { + + // CustomFields has @JsonAdapter(CustomerFieldsAdapter::class) so plain Gson() picks it up. + private val gson = Gson() + + // region read + + @Test + fun `read - string field deserialized correctly`() { + val fields = gson.fromJson("""{"name":"John"}""", CustomFields::class.java) + assertNotNull(fields) + assertEquals("John", fields.fields?.get("name")) + } + + @Test + fun `read - numeric field deserialized as Double (GSON default for numbers)`() { + val fields = gson.fromJson("""{"age":30}""", CustomFields::class.java) + assertNotNull(fields) + // GSON deserializes JSON numbers into Map as Double + assertEquals(30.0, fields.fields?.get("age")) + } + + @Test + fun `read - boolean field deserialized correctly`() { + val fields = gson.fromJson("""{"active":true}""", CustomFields::class.java) + assertNotNull(fields) + assertEquals(true, fields.fields?.get("active")) + } + + @Test + fun `read - null field value deserialized as null`() { + val fields = gson.fromJson("""{"optional":null}""", CustomFields::class.java) + assertNotNull(fields) + assertNull(fields.fields?.get("optional")) + } + + @Test + fun `read - multiple fields deserialized correctly`() { + val fields = gson.fromJson( + """{"name":"Alice","score":99.5,"active":false}""", + CustomFields::class.java + ) + assertNotNull(fields) + assertEquals("Alice", fields.fields?.get("name")) + assertEquals(99.5, fields.fields?.get("score")) + assertEquals(false, fields.fields?.get("active")) + } + + @Test + fun `read - empty object produces empty map`() { + val fields = gson.fromJson("""{}""", CustomFields::class.java) + assertNotNull(fields) + assertTrue(fields.fields?.isEmpty() ?: false) + } + + @Test + fun `read - JSON null returns null CustomFields`() { + val fields = gson.fromJson("null", CustomFields::class.java) + assertNull(fields) + } + + // endregion + + // region write + + @Test + fun `write - string field serialized as JSON string`() { + val fields = CustomFields("city" to "Moscow") + val json = gson.toJson(fields) + assertTrue(json.contains(""""city":"Moscow"""")) + } + + @Test + fun `write - null CustomFields serialized as JSON null`() { + val json = gson.toJson(null as CustomFields?) + assertEquals("null", json) + } + + @Test + fun `write - CustomFields with null fields map serialized as JSON null`() { + val fields = CustomFields(fields = null) + val json = gson.toJson(fields) + assertEquals("null", json) + } + + @Test + fun `write - multiple fields serialized correctly`() { + val fields = CustomFields("a" to "1", "b" to 2) + val json = gson.toJson(fields) + assertTrue(json.contains(""""a":"1"""")) + assertTrue(json.contains(""""b":2""")) + } + + @Test + fun `write - null value in fields is dropped by Gson`() { + // CustomerFieldsAdapter.write() calls gson.toJson(value.fields). + // GSON's nullValue() with serializeNulls=false silently drops name+null pairs + // even inside maps, so null field values are absent from the output. + val fields = CustomFields(mapOf("key" to null as Any?)) + val json = gson.toJson(fields) + assertEquals("{}", json) + } + + // endregion + + // region convertTo + + @Test + fun `convertTo - maps fields to typed data class`() { + data class Profile(val name: String?, val age: Double?) + + val fields = CustomFields("name" to "Bob", "age" to 25.0) + val profile = fields.convertTo(Profile::class.java) + + assertNotNull(profile) + assertEquals("Bob", profile!!.name) + assertEquals(25.0, profile.age) + } + + @Test + fun `convertTo - returns null for null fields map`() { + val fields = CustomFields(fields = null) + val result = fields.convertTo(Map::class.java) + assertNull(result) + } + + // endregion + + // region round-trip + + @Test + fun `round-trip - string fields preserved`() { + val original = CustomFields("x" to "hello", "y" to "world") + val json = gson.toJson(original) + val restored = gson.fromJson(json, CustomFields::class.java) + assertNotNull(restored) + assertEquals("hello", restored.fields?.get("x")) + assertEquals("world", restored.fields?.get("y")) + } + + // endregion +} diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/models/operation/adapters/DateOnlyAdapterTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/models/operation/adapters/DateOnlyAdapterTest.kt new file mode 100644 index 00000000..cacbfd93 --- /dev/null +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/models/operation/adapters/DateOnlyAdapterTest.kt @@ -0,0 +1,144 @@ +package cloud.mindbox.mobile_sdk.models.operation.adapters + +import cloud.mindbox.mobile_sdk.models.operation.DateOnly +import com.google.gson.GsonBuilder +import com.google.gson.annotations.SerializedName +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.text.SimpleDateFormat +import java.util.Locale + +class DateOnlyAdapterTest { + + private val gson = GsonBuilder().registerTypeAdapter(DateOnly::class.java, DateOnlyAdapter()).create() + + private data class Holder( + @SerializedName("date") + val date: DateOnly? + ) + + // region read + + @Test + fun `read - parses yyyy-MM-dd format`() { + val holder = gson.fromJson("""{"date":"2023-06-15"}""", Holder::class.java) + assertNotNull(holder.date) + val formatter = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()) + val expected = formatter.parse("2023-06-15")!! + assertEquals(expected.time, holder.date!!.time) + } + + @Test + fun `read - null JSON value returns null`() { + val holder = gson.fromJson("""{"date":null}""", Holder::class.java) + assertNull(holder.date) + } + + @Test + fun `read - missing field returns null`() { + val holder = gson.fromJson("""{}""", Holder::class.java) + assertNull(holder.date) + } + + @Test + fun `read - invalid date string returns null without throwing`() { + val holder = gson.fromJson("""{"date":"not-a-date"}""", Holder::class.java) + assertNull(holder.date) + } + + @Test + fun `read - empty string returns null without throwing`() { + val holder = gson.fromJson("""{"date":""}""", Holder::class.java) + assertNull(holder.date) + } + + @Test + fun `read - parses start of year`() { + val holder = gson.fromJson("""{"date":"2023-01-01"}""", Holder::class.java) + assertNotNull(holder.date) + val formatter = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()) + assertEquals(formatter.parse("2023-01-01")!!.time, holder.date!!.time) + } + + @Test + fun `read - parses end of year`() { + val holder = gson.fromJson("""{"date":"2023-12-31"}""", Holder::class.java) + assertNotNull(holder.date) + val formatter = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()) + assertEquals(formatter.parse("2023-12-31")!!.time, holder.date!!.time) + } + + // endregion + + // region write + + @Test + fun `write - null DateOnly field omitted by default Gson serialization`() { + // GSON skips null fields in POJOs by default (serializeNulls not set). + val json = gson.toJson(Holder(null)) + assertEquals("{}", json) + } + + @Test + fun `write - null DateOnly writes JSON null when adapter called directly`() { + val sw = java.io.StringWriter() + val writer = com.google.gson.stream.JsonWriter(sw) + DateOnlyAdapter().write(writer, null) + writer.flush() + assertEquals("null", sw.toString()) + } + + @Test + fun `write - DateOnly serialized as yyyy-MM-dd string`() { + // Pin a known date to avoid locale/TZ ambiguity: use local midnight to avoid rollover. + val formatter = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()) + val date = formatter.parse("2023-06-15")!! + + val json = gson.toJson(Holder(DateOnly(date.time))) + + assertTrue( + "Serialized DateOnly should match yyyy-MM-dd pattern", json.contains(""""2023-06-15"""") + ) + } + + @Test + fun `write - two DateOnly with same timestamp produce same output`() { + val formatter = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()) + val time = formatter.parse("2023-06-15")!!.time + assertEquals( + gson.toJson(Holder(DateOnly(time))), gson.toJson(Holder(DateOnly(time))) + ) + } + + // endregion + + // region round-trip + + @Test + fun `round trip - date string preserved after read then write`() { + val originalJson = """{"date":"2023-06-15"}""" + val holder = gson.fromJson(originalJson, Holder::class.java) + assertNotNull(holder.date) + + val serialized = gson.toJson(holder) + assertTrue( + "Round-tripped JSON should still contain the date string", serialized.contains(""""2023-06-15"""") + ) + } + + @Test + fun `read - preserves date independent of UTC offset`() { + // Documents that DateOnly uses local formatter without timezone handling. + // This test pins the expected value using the same local formatter the adapter uses. + val localFormatter = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()) + val knownDate = "2023-06-15" + val holder = gson.fromJson("""{"date":"$knownDate"}""", Holder::class.java) + assertNotNull(holder.date) + assertEquals(knownDate, localFormatter.format(holder.date!!)) + } + + // endregion +} diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/models/operation/adapters/DateTimeAdapterTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/models/operation/adapters/DateTimeAdapterTest.kt new file mode 100644 index 00000000..6446cc13 --- /dev/null +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/models/operation/adapters/DateTimeAdapterTest.kt @@ -0,0 +1,161 @@ +package cloud.mindbox.mobile_sdk.models.operation.adapters + +import cloud.mindbox.mobile_sdk.models.operation.DateTime +import com.google.gson.GsonBuilder +import com.google.gson.annotations.SerializedName +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.text.SimpleDateFormat +import java.util.Locale +import java.util.TimeZone + +class DateTimeAdapterTest { + + private val gson = GsonBuilder() + .registerTypeAdapter(DateTime::class.java, DateTimeAdapter()) + .create() + + private data class Holder( + @SerializedName("date") + val date: DateTime? + ) + + // region read + + @Test + fun `read - ISO8601 with positive timezone offset`() { + val holder = gson.fromJson("""{"date":"2023-06-15T10:30:00.000+03:00"}""", Holder::class.java) + assertNotNull(holder.date) + val expected = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX", Locale.US) + .parse("2023-06-15T10:30:00.000+03:00")!! + assertEquals(expected.time, holder.date!!.time) + } + + @Test + fun `read - ISO8601 UTC Z suffix`() { + val holder = gson.fromJson("""{"date":"2023-01-01T00:00:00.000Z"}""", Holder::class.java) + assertNotNull(holder.date) + val expected = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX", Locale.US) + .parse("2023-01-01T00:00:00.000+00:00")!! + assertEquals(expected.time, holder.date!!.time) + } + + @Test + fun `read - ISO8601 without milliseconds`() { + val holder = gson.fromJson("""{"date":"2023-06-15T10:30:00+00:00"}""", Holder::class.java) + assertNotNull(holder.date) + val expected = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssXXX", Locale.US) + .parse("2023-06-15T10:30:00+00:00")!! + assertEquals(expected.time, holder.date!!.time) + } + + @Test + fun `read - ISO8601 negative timezone offset`() { + val holder = gson.fromJson("""{"date":"2023-06-15T10:30:00.000-05:00"}""", Holder::class.java) + assertNotNull(holder.date) + val expected = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX", Locale.US) + .parse("2023-06-15T10:30:00.000-05:00")!! + assertEquals(expected.time, holder.date!!.time) + } + + @Test + fun `read - null JSON value returns null DateTime`() { + val holder = gson.fromJson("""{"date":null}""", Holder::class.java) + assertNull(holder.date) + } + + @Test + fun `read - missing field returns null DateTime`() { + val holder = gson.fromJson("""{}""", Holder::class.java) + assertNull(holder.date) + } + + @Test + fun `read - invalid date string returns null without throwing`() { + // LoggingExceptionHandler swallows the parse failure + val holder = gson.fromJson("""{"date":"not-a-date"}""", Holder::class.java) + assertNull(holder.date) + } + + @Test + fun `read - empty string returns null without throwing`() { + val holder = gson.fromJson("""{"date":""}""", Holder::class.java) + assertNull(holder.date) + } + + // endregion + + // region write + + @Test + fun `write - null DateTime field omitted by default Gson serialization`() { + // GSON skips null fields in POJOs by default (serializeNulls not set), + // so the adapter's nullValue() path is not reached via reflection. + val json = gson.toJson(Holder(null)) + assertEquals("{}", json) + } + + @Test + fun `write - null DateTime writes JSON null when adapter called directly`() { + val sw = java.io.StringWriter() + val writer = com.google.gson.stream.JsonWriter(sw) + DateTimeAdapter().write(writer, null) + writer.flush() + assertEquals("null", sw.toString()) + } + + @Test + fun `write - DateTime serialized as non-null string`() { + val json = gson.toJson(Holder(DateTime(0L))) + assertTrue("JSON should contain string date", json.contains(""""date":"""")) + } + + @Test + fun `write - uses dd_MM_yyyy_HH_mm_ss_FFF pattern`() { + // Documents the exact write format so migration doesn't accidentally change it. + // NOTE: FFF in SimpleDateFormat is "Day of week in month", NOT milliseconds. + // This is a known quirk of the current implementation. + val knownTime = 1_700_000_000_000L + val dateTime = DateTime(knownTime) + val formatter = SimpleDateFormat("dd.MM.yyyy HH:mm:ss.FFF", Locale.getDefault()) + val expectedDateString = formatter.format(dateTime) + + val json = gson.toJson(Holder(dateTime)) + assertTrue( + "Serialized date should match 'dd.MM.yyyy HH:mm:ss.FFF' pattern", + json.contains(""""$expectedDateString"""") + ) + } + + @Test + fun `write - two DateTimes with same timestamp produce same output`() { + val time = 1_686_825_000_000L + val json1 = gson.toJson(Holder(DateTime(time))) + val json2 = gson.toJson(Holder(DateTime(time))) + assertEquals(json1, json2) + } + + // endregion + + // region timestamp preservation + + @Test + fun `read - preserves epoch milliseconds from ISO8601 input`() { + // The server sends ISO8601; we must preserve the exact timestamp. + // This is the key regression test for the ISO8601Utils → alternative migration. + val utcSdf = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX", Locale.US).also { + it.timeZone = TimeZone.getTimeZone("UTC") + } + val expectedMillis = utcSdf.parse("2023-06-15T07:30:00.000+00:00")!!.time + + val holder = gson.fromJson("""{"date":"2023-06-15T07:30:00.000+00:00"}""", Holder::class.java) + + assertNotNull(holder.date) + assertEquals(expectedMillis, holder.date!!.time) + } + + // endregion +} diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/models/operation/adapters/IdsAdapterTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/models/operation/adapters/IdsAdapterTest.kt new file mode 100644 index 00000000..2a80ddad --- /dev/null +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/models/operation/adapters/IdsAdapterTest.kt @@ -0,0 +1,132 @@ +package cloud.mindbox.mobile_sdk.models.operation.adapters + +import cloud.mindbox.mobile_sdk.models.operation.Ids +import com.google.gson.Gson +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Tests for [IdsAdapter]. + * + * Key behavior under test: GSON by default parses integer JSON values into Double + * (e.g. 12345 → "12345.0"). The adapter explicitly uses [com.google.gson.stream.JsonReader.nextString] + * on every value so that numeric IDs are read as raw strings without the ".0" suffix. + */ +class IdsAdapterTest { + + // Ids has @JsonAdapter(IdsAdapter::class) so plain Gson() picks it up automatically. + private val gson = Gson() + + // region read + + @Test + fun `read - string ID preserved as-is`() { + val ids = gson.fromJson("""{"mindboxId":"abc123"}""", Ids::class.java) + assertNotNull(ids) + assertEquals("abc123", ids.ids["mindboxId"]) + } + + @Test + fun `read - integer ID read as string without dot-zero suffix`() { + // Critical regression test: without the adapter workaround, GSON would + // deserialise 12345 as the Double 12345.0, producing "12345.0" in the map. + val ids = gson.fromJson("""{"mindboxId":12345}""", Ids::class.java) + assertNotNull(ids) + assertEquals("12345", ids.ids["mindboxId"]) + } + + @Test + fun `read - large integer ID preserved without scientific notation`() { + val ids = gson.fromJson("""{"externalId":9999999999}""", Ids::class.java) + assertNotNull(ids) + assertEquals("9999999999", ids.ids["externalId"]) + } + + @Test + fun `read - multiple IDs of mixed types`() { + val ids = gson.fromJson( + """{"mindboxId":42,"email":"user@example.com","loyaltyId":7}""", + Ids::class.java + ) + assertNotNull(ids) + assertEquals("42", ids.ids["mindboxId"]) + assertEquals("user@example.com", ids.ids["email"]) + assertEquals("7", ids.ids["loyaltyId"]) + } + + @Test + fun `read - empty object produces empty map`() { + val ids = gson.fromJson("""{}""", Ids::class.java) + assertNotNull(ids) + assertTrue(ids.ids.isEmpty()) + } + + @Test + fun `read - JSON null returns null Ids`() { + val ids = gson.fromJson("null", Ids::class.java) + assertNull(ids) + } + + // endregion + + // region write + + @Test + fun `write - string ID serialized as JSON string`() { + val ids = Ids("mindboxId" to "abc123") + val json = gson.toJson(ids) + assertTrue(json.contains(""""mindboxId":"abc123"""")) + } + + @Test + fun `write - null Ids serialized as JSON null`() { + val json = gson.toJson(null as Ids?) + assertEquals("null", json) + } + + @Test + fun `write - multiple IDs serialized correctly`() { + val ids = Ids("mindboxId" to "42", "email" to "user@example.com") + val json = gson.toJson(ids) + assertTrue(json.contains(""""mindboxId":"42"""")) + assertTrue(json.contains(""""email":"user@example.com"""")) + } + + @Test + fun `write - null value in map is dropped by Gson`() { + // GSON's nullValue() with serializeNulls=false silently drops name+null map pairs. + // An Ids entry with a null value is absent from the serialized output. + val ids = Ids(mapOf("mindboxId" to null)) + val json = gson.toJson(ids) + assertEquals("{}", json) + } + + // endregion + + // region round-trip + + @Test + fun `round-trip - integer ID survives serialize then deserialize`() { + // Ids stores everything as String, so we start with a string "42". + val original = Ids("mindboxId" to "42") + val json = gson.toJson(original) + val restored = gson.fromJson(json, Ids::class.java) + assertNotNull(restored) + assertEquals("42", restored.ids["mindboxId"]) + } + + @Test + fun `round-trip - multiple string IDs preserved`() { + // null values are excluded: IdsAdapter.read() hangs on null map values (see bug comment above). + val original = Ids("a" to "1", "b" to "hello") + val json = gson.toJson(original) + val restored = gson.fromJson(json, Ids::class.java) + assertNotNull(restored) + assertEquals(original.ids, restored.ids) + } + + // endregion +} diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/models/operation/adapters/ProductListResponseAdapterTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/models/operation/adapters/ProductListResponseAdapterTest.kt new file mode 100644 index 00000000..1cb22d04 --- /dev/null +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/models/operation/adapters/ProductListResponseAdapterTest.kt @@ -0,0 +1,146 @@ +package cloud.mindbox.mobile_sdk.models.operation.adapters + +import cloud.mindbox.mobile_sdk.models.operation.response.CatalogProductListResponse +import cloud.mindbox.mobile_sdk.models.operation.response.ProductListItemResponse +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Tests for [ProductListResponseAdapter]. + * + * The adapter dispatches on the JSON token type: + * - BEGIN_ARRAY → List + * - BEGIN_OBJECT → CatalogProductListResponse + * - NULL → null + */ +class ProductListResponseAdapterTest { + + private val adapter = ProductListResponseAdapter() + + private fun read(json: String): Any? { + val reader = com.google.gson.stream.JsonReader(java.io.StringReader(json)) + return adapter.read(reader) + } + + private fun write(value: Any?): String { + val sw = java.io.StringWriter() + val writer = com.google.gson.stream.JsonWriter(sw) + adapter.write(writer, value) + writer.flush() + return sw.toString() + } + + // region read – array input + + @Test + fun `read - JSON array deserializes to list of ProductListItemResponse`() { + val result = read("""[{"count":2.0,"pricePerItem":99.9},{"count":1.0}]""") + assertNotNull(result) + assertTrue(result is List<*>) + @Suppress("UNCHECKED_CAST") + val list = result as List + assertEquals(2, list.size) + assertEquals(2.0, list[0].count) + assertEquals(99.9, list[0].pricePerItem) + } + + @Test + fun `read - empty JSON array deserializes to empty list`() { + val result = read("[]") + assertNotNull(result) + assertTrue(result is List<*>) + assertTrue((result as List<*>).isEmpty()) + } + + // endregion + + // region read – object input + + @Test + fun `read - JSON object deserializes to CatalogProductListResponse`() { + val result = read("""{"processingStatus":"Success","items":[]}""") + assertNotNull(result) + assertTrue(result is CatalogProductListResponse) + val catalog = result as CatalogProductListResponse + assertNotNull(catalog.items) + assertTrue(catalog.items!!.isEmpty()) + } + + @Test + fun `read - empty JSON object deserializes to CatalogProductListResponse`() { + val result = read("""{}""") + assertNotNull(result) + assertTrue(result is CatalogProductListResponse) + } + + // endregion + + // region read – null input + + @Test + fun `read - JSON null returns null`() { + val result = read("null") + assertNull(result) + } + + // endregion + + // region write + + @Test + fun `write - list serialized as JSON array`() { + val list = listOf(ProductListItemResponse(count = 3.0)) + val json = write(list) + assertTrue(json.startsWith("[")) + assertTrue(json.endsWith("]")) + assertTrue(json.contains(""""count":3.0""")) + } + + @Test + fun `write - CatalogProductListResponse serialized as JSON object`() { + val catalog = CatalogProductListResponse(items = emptyList()) + val json = write(catalog) + assertTrue(json.startsWith("{")) + assertTrue(json.endsWith("}")) + } + + @Test + fun `write - null serialized as JSON null`() { + val json = write(null) + assertEquals("null", json) + } + + // endregion + + // region round-trip + + @Test + fun `round-trip - list of items preserved`() { + val original = listOf( + ProductListItemResponse(count = 1.0, price = 50.0), + ProductListItemResponse(count = 2.0, price = 100.0), + ) + val json = write(original) + val restored = read(json) + + assertTrue(restored is List<*>) + @Suppress("UNCHECKED_CAST") + val list = restored as List + assertEquals(2, list.size) + assertEquals(1.0, list[0].count) + assertEquals(2.0, list[1].count) + } + + @Test + fun `round-trip - CatalogProductListResponse preserved`() { + val original = CatalogProductListResponse(items = emptyList()) + val json = write(original) + val restored = read(json) + assertTrue(restored is CatalogProductListResponse) + } + + // endregion +} From c02e049c0374b4aafb4f615cc86752e8b7564184 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 19 May 2026 12:41:53 +0000 Subject: [PATCH 15/15] Bump SDK version to 2.15.2 --- example/app/build.gradle | 2 +- gradle.properties | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/example/app/build.gradle b/example/app/build.gradle index 9aa14420..18dd0238 100644 --- a/example/app/build.gradle +++ b/example/app/build.gradle @@ -92,7 +92,7 @@ dependencies { implementation 'com.google.code.gson:gson:2.11.0' //Mindbox - implementation 'cloud.mindbox:mobile-sdk:2.15.1' + implementation 'cloud.mindbox:mobile-sdk:2.15.2' implementation 'cloud.mindbox:mindbox-firebase' implementation 'cloud.mindbox:mindbox-huawei' implementation 'cloud.mindbox:mindbox-rustore' diff --git a/gradle.properties b/gradle.properties index 3649f2cc..fe509537 100644 --- a/gradle.properties +++ b/gradle.properties @@ -20,7 +20,7 @@ android.enableJetifier=true # Kotlin code style for this project: "official" or "obsolete": kotlin.code.style=official # SDK version property -SDK_VERSION_NAME=2.15.1 +SDK_VERSION_NAME=2.15.2 USE_LOCAL_MINDBOX_COMMON=true android.nonTransitiveRClass=false kotlin.mpp.androidGradlePluginCompatibility.nowarn=true