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 3f511acab..2355fca4e 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/Mindbox.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/Mindbox.kt index 5b9558ec4..25a12a5d6 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 fbf90f7e9..b6c10f981 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 079233fe2..6fe733327 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,68 @@ 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 { + val trimmed = hostOrUrl.trim() + return if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) { + trimmed.trimEnd('/') + } else { + "https://${trimmed.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 +83,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 2dd127f13..09d7a6655 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 097abc998..42abb68db 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 = operationsDomainConfigPolicyAction(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 000000000..87d55f28b --- /dev/null +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/OperationsDomainConfigPolicy.kt @@ -0,0 +1,31 @@ +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 fun operationsDomainConfigPolicyAction( + raw: String?, + currentlyStored: String?, +): OperationsDomainConfigPolicyAction { + val value = raw?.trim()?.takeIf { it.isNotBlank() } + ?: return if (currentlyStored != null) { + OperationsDomainConfigPolicyAction.Clear + } else { + 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 7a856bef5..d30c77db9 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( @@ -309,7 +332,7 @@ internal class GatewayManager(private val mindboxServiceGenerator: MindboxServic } else { try { JSONObject(body) - } catch (e: JSONException) { + } catch (_: JSONException) { null } } @@ -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 6d4200436..c0b664e07 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 f42155dfd..de99b37d1 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 8a3214a18..ddf82ac7d 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 cf325fdf5..edb7a6d5f 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,15 @@ internal object MindboxPreferences { SharedPreferencesManager.put(KEY_LOCAL_STATE_VERSION, value) } } + + 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) + } + } } 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 000000000..1761acb9d --- /dev/null +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/SdkValidationDomainTest.kt @@ -0,0 +1,119 @@ +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/")) + } + + @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 new file mode 100644 index 000000000..5fbb7df64 --- /dev/null +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/OperationsDomainConfigPolicyTest.kt @@ -0,0 +1,192 @@ +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(VALID_HOST_WITH_TRAILING_SLASH) } returns true + every { SdkValidation.isValidDomain(ANOTHER_VALID_HOST) } returns true + } + + @After + fun tearDown() { + unmockkObject(SdkValidation) + } + + // region raw null / empty — backend omitted value: clear if stored, keep if nothing to clear + + @Test + fun `raw null stored null returns Keep`() { + val result = operationsDomainConfigPolicyAction(raw = null, currentlyStored = null) + + assertEquals(OperationsDomainConfigPolicyAction.Keep, result) + } + + @Test + fun `raw null stored has value returns Clear`() { + val result = operationsDomainConfigPolicyAction(raw = null, currentlyStored = VALID_HOST) + + assertEquals(OperationsDomainConfigPolicyAction.Clear, result) + } + + @Test + fun `raw empty stored has value returns Clear`() { + val result = operationsDomainConfigPolicyAction(raw = "", currentlyStored = VALID_HOST) + + assertEquals(OperationsDomainConfigPolicyAction.Clear, result) + } + + @Test + fun `raw blank stored has value returns Clear`() { + val result = operationsDomainConfigPolicyAction(raw = " ", currentlyStored = VALID_HOST) + + assertEquals(OperationsDomainConfigPolicyAction.Clear, 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 = operationsDomainConfigPolicyAction( + raw = "not a valid domain!!", + currentlyStored = VALID_HOST + ) + + assertEquals(OperationsDomainConfigPolicyAction.Keep, result) + } + + @Test + fun `raw invalid domain no stored value returns Keep`() { + val result = operationsDomainConfigPolicyAction( + 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 = operationsDomainConfigPolicyAction(raw = VALID_HOST, currentlyStored = null) + + assertEquals(OperationsDomainConfigPolicyAction.Save(VALID_HOST), result) + } + + @Test + fun `raw valid domain same as stored returns Keep`() { + val result = operationsDomainConfigPolicyAction( + 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 = operationsDomainConfigPolicyAction( + 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 = operationsDomainConfigPolicyAction( + 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 = operationsDomainConfigPolicyAction( + raw = VALID_HOST_WITH_SCHEME, + currentlyStored = VALID_HOST_WITH_SCHEME + ) + + 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 = operationsDomainConfigPolicyAction( + 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 = operationsDomainConfigPolicyAction( + 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 VALID_HOST_WITH_TRAILING_SLASH = "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 4c3208270..b92fc3d20 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 @@ -37,8 +39,180 @@ class GatewayManagerTest { mockkObject(MindboxPreferences) every { MindboxPreferences.deviceUuid } returns "test-device-uuid-123" + every { MindboxPreferences.operationsDomainFromConfig } returns null } + @After + fun onTestEnd() { + unmockkObject(MindboxPreferences) + } + + // 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 5a22a40b7..4fbcfde9a 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()) }