From 1bb20186ad35fa16b9adc6f055d6966bece1782f Mon Sep 17 00:00:00 2001 From: Egor Kitselyuk Date: Fri, 9 Oct 2026 17:12:06 +0300 Subject: [PATCH] MOBILE-583: Disable tracking for operationDomains --- kmp-common-sdk | 2 +- .../mobile_sdk/MindboxConfiguration.kt | 7 ++ .../managers/TrackingIdsResolver.kt | 8 ++ .../managers/TrackingIdsResolverTest.kt | 109 ++++++++++++++++++ 4 files changed, 125 insertions(+), 1 deletion(-) diff --git a/kmp-common-sdk b/kmp-common-sdk index bf69265f..f03f1897 160000 --- a/kmp-common-sdk +++ b/kmp-common-sdk @@ -1 +1 @@ -Subproject commit bf69265fec8f09bd93d7b8d10eac3a41320591dd +Subproject commit f03f1897a35519502cbe513adc8dc3d4a3a352a9 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 e77d05da..b57f5b5a 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/MindboxConfiguration.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/MindboxConfiguration.kt @@ -199,6 +199,10 @@ public class MindboxConfiguration private constructor( * Unlike most options here this one is re-read on every initialization, so it can be * turned on or off in a later app version. * + * Projects that route operations through an anonymizer never collect the identifier, + * whatever this option says: collection stops as soon as [operationsDomain] is set or the + * mobile config supplies an operations domain. + * * @param disableTrackingIds - flag which turns the collection off. Default value is false, * meaning the identifier is collected. */ @@ -214,6 +218,9 @@ public class MindboxConfiguration private constructor( * (e.g. "domain.com/api/v2") — operation endpoints are appended after it. * A blank value is treated as not set. An invalid value is logged * and ignored during SDK initialization. + * + * While an operations domain is set, the SDK does not collect the device tracking + * identifier (see [disableTrackingIds]). */ public fun operationsDomain(operationsDomain: String): Builder { this.operationsDomain = operationsDomain.trim().takeIf { it.isNotBlank() } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/managers/TrackingIdsResolver.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/managers/TrackingIdsResolver.kt index 33bba8a0..b3f1d545 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/managers/TrackingIdsResolver.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/managers/TrackingIdsResolver.kt @@ -38,6 +38,10 @@ internal class TrackingIdsResolverImpl( override suspend fun resolve(context: Context): List { if (!MindboxPreferences.shouldCollectTrackingIds) return emptyList() + operationsDomain()?.let { domain -> + mindboxLogI("Tracking ids are not collected: operations go through operationsDomain $domain") + return emptyList() + } val reading = scope().async(ioDispatcher) { pushServiceHandlers().map { handler -> handler.tryGetTrackingId(context) } @@ -59,6 +63,10 @@ internal class TrackingIdsResolverImpl( mindboxLogI("Sent tracking ids: ${trackingIds.joinToString { it.type }.ifEmpty { "none" }}") } + private fun operationsDomain(): String? = + MindboxPreferences.operationsDomainFromConfig + ?: DbManager.getConfigurations()?.operationsDomain?.takeIf { it.isNotBlank() } + private fun lastSent(): List = loggingRunCatching(defaultValue = emptyList()) { MindboxPreferences.lastSentTrackingIds .takeIf { it.isNotBlank() } diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/managers/TrackingIdsResolverTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/managers/TrackingIdsResolverTest.kt index 868e8ea8..e9c0b2e7 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/managers/TrackingIdsResolverTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/managers/TrackingIdsResolverTest.kt @@ -1,6 +1,7 @@ package cloud.mindbox.mobile_sdk.managers import android.content.Context +import cloud.mindbox.mobile_sdk.models.Configuration import cloud.mindbox.mobile_sdk.models.TrackingId import cloud.mindbox.mobile_sdk.pushes.PushServiceHandler import cloud.mindbox.mobile_sdk.pushes.TrackingIdResult @@ -9,8 +10,10 @@ import io.mockk.every import io.mockk.mockk import io.mockk.mockkObject import io.mockk.unmockkObject +import io.mockk.verify import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest import org.junit.After @@ -28,13 +31,17 @@ private const val STORED_BOTH = """[{"type":"google","value":"38400000-8cf0-11bd-b23e-10b96e40000d"},{"type":"huawei","value":"1a2b3c4d-5e6f-7081-92a3-b4c5d6e7f809"}]""" private const val STORED_GOOGLE = """[{"type":"google","value":"38400000-8cf0-11bd-b23e-10b96e40000d"}]""" +private const val ANONYMIZER_DOMAIN = "anonymizer.example.com" +@OptIn(ExperimentalCoroutinesApi::class) class TrackingIdsResolverImplTest { private val context = mockk() private var shouldCollect = true private var lastSent = "" + private var operationsDomainFromConfig: String? = null + private var configuration: Configuration? = storedConfiguration(operationsDomain = null) @Before fun onTestStart() { @@ -42,13 +49,30 @@ class TrackingIdsResolverImplTest { every { MindboxPreferences.shouldCollectTrackingIds } answers { shouldCollect } every { MindboxPreferences.lastSentTrackingIds } answers { lastSent } every { MindboxPreferences.lastSentTrackingIds = any() } answers { lastSent = firstArg() } + every { MindboxPreferences.operationsDomainFromConfig } answers { operationsDomainFromConfig } + mockkObject(DbManager) + every { DbManager.getConfigurations() } answers { configuration } } @After fun onTestEnd() { unmockkObject(MindboxPreferences) + unmockkObject(DbManager) } + private fun storedConfiguration(operationsDomain: String?) = Configuration( + previousInstallationId = "", + previousDeviceUUID = "", + endpointId = "test-endpoint", + domain = "api.mindbox.ru", + packageName = "com.test.app", + versionName = "1.0.0", + versionCode = "1", + subscribeCustomerIfCreated = true, + shouldCreateCustomer = true, + operationsDomain = operationsDomain, + ) + private fun resolver(vararg results: TrackingIdResult) = TrackingIdsResolverImpl( pushServiceHandlers = { results.map(::handlerReturning) }, scope = { CoroutineScope(UnconfinedTestDispatcher()) }, @@ -205,6 +229,91 @@ class TrackingIdsResolverImplTest { assertEquals(emptyList(), payload) } + @Test + fun `nothing is collected when operationsDomain is set in init`() = runTest { + configuration = storedConfiguration(operationsDomain = ANONYMIZER_DOMAIN) + + val payload = resolver(success(GOOGLE, GOOGLE_VALUE)).resolve(context) + + assertEquals(emptyList(), payload) + } + + @Test + fun `nothing is collected when operationsDomain comes from the mobile config`() = runTest { + operationsDomainFromConfig = ANONYMIZER_DOMAIN + + val payload = resolver(success(GOOGLE, GOOGLE_VALUE)).resolve(context) + + assertEquals(emptyList(), payload) + } + + @Test + fun `providers are not even read when operationsDomain is set`() = runTest { + operationsDomainFromConfig = ANONYMIZER_DOMAIN + val handler = handlerReturning(success(GOOGLE, GOOGLE_VALUE)) + val resolver = TrackingIdsResolverImpl( + pushServiceHandlers = { listOf(handler) }, + scope = { CoroutineScope(UnconfinedTestDispatcher()) }, + ioDispatcher = UnconfinedTestDispatcher(), + ) + + resolver.resolve(context) + + verify(exactly = 0) { handler.tryGetTrackingId(any()) } + } + + @Test + fun `operationsDomain does not repeat the last reported set`() = runTest { + lastSent = STORED_BOTH + configuration = storedConfiguration(operationsDomain = ANONYMIZER_DOMAIN) + + val payload = resolver(TrackingIdResult.Unavailable).resolve(context) + + assertEquals(emptyList(), payload) + } + + @Test + fun `operationsDomain added to the mobile config stops the collection`() = runTest { + val resolver = resolver(success(GOOGLE, GOOGLE_VALUE)) + resolver.markSent(resolver.resolve(context)) + + operationsDomainFromConfig = ANONYMIZER_DOMAIN + val payload = resolver.resolve(context) + + assertEquals(emptyList(), payload) + assertTrue(resolver.hasChanged(payload)) + } + + @Test + fun `operationsDomain removed from the mobile config resumes the collection`() = runTest { + val resolver = resolver(success(GOOGLE, GOOGLE_VALUE)) + operationsDomainFromConfig = ANONYMIZER_DOMAIN + resolver.markSent(resolver.resolve(context)) + + operationsDomainFromConfig = null + val payload = resolver.resolve(context) + + assertEquals(listOf(googleId), payload) + } + + @Test + fun `a blank operationsDomain in the stored configuration does not stop the collection`() = runTest { + configuration = storedConfiguration(operationsDomain = " ") + + val payload = resolver(success(GOOGLE, GOOGLE_VALUE)).resolve(context) + + assertEquals(listOf(googleId), payload) + } + + @Test + fun `a missing stored configuration does not stop the collection`() = runTest { + configuration = null + + val payload = resolver(success(GOOGLE, GOOGLE_VALUE)).resolve(context) + + assertEquals(listOf(googleId), payload) + } + @Test fun `an outage is never a change, so it triggers no operation of its own`() = runTest { lastSent = STORED_GOOGLE