diff --git a/.github/workflows/lint_unitTests_build.yml b/.github/workflows/lint_unitTests_build.yml index 1cb614ca9..73064f50d 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 1159082ec..55faa13a8 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/example/app/build.gradle b/example/app/build.gradle index 660c20dac..18dd0238c 100644 --- a/example/app/build.gradle +++ b/example/app/build.gradle @@ -87,12 +87,12 @@ 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' //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 3649f2cc9..fe5095378 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 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 87cf36ca8..926a903d3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -39,8 +39,9 @@ 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 = "6.10.0" +pushclient = "7.2.0" [bundles] buildscript-plugins = [ @@ -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 3d607614e..e1b436a48 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' 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 9ad7a86e2..25a12a5d6 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/Mindbox.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/Mindbox.kt @@ -14,9 +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.di.MindboxDI import cloud.mindbox.mobile_sdk.di.mindboxInject import cloud.mindbox.mobile_sdk.inapp.data.managers.SessionStorageManager @@ -735,18 +732,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 * @@ -1386,6 +1437,7 @@ public object Mindbox : MindboxLog { endpointId = configuration.endpointId, previousDeviceUUID = configuration.previousDeviceUUID, previousInstallationId = configuration.previousInstallationId, + operationsDomain = configuration.operationsDomain, ) return if (validationErrors.isEmpty()) { @@ -1395,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..ed2f7f34f 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(true, "The operationsDomain is not valid"); 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/inapp/presentation/InAppCallbackWrapper.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppCallbackWrapper.kt index dfa7ff7c3..42f3745c2 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/InAppMessageManager.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageManager.kt index 239067800..a44911349 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 15edca038..f8ee03ee6 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 db2a4fdbb..6caf48ff8 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 29bcc769a..93af840a7 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) { @@ -168,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() } 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 d78d6e113..b1a4e537d 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 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/inapp/presentation/InAppMessageViewDisplayerImplTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayerImplTest.kt index 548652e78..6aa831908 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 + } } 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()) }