diff --git a/WebViewLocalStateStorageTests.swift b/WebViewLocalStateStorageTests.swift new file mode 100644 index 000000000..26b0b44c6 --- /dev/null +++ b/WebViewLocalStateStorageTests.swift @@ -0,0 +1,279 @@ +// +// WebViewLocalStateStorageTests.swift +// MindboxTests +// +// Created by Sergei Semko on 3/11/26. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import Testing +@testable import Mindbox + +@Suite("WebViewLocalStateStorage", .tags(.webView)) +struct WebViewLocalStateStorageTests { + + private let testSuiteName = "cloud.Mindbox.test.webview.localState" + private let keyPrefix = Constants.WebViewLocalState.keyPrefix + + private func makeSUT() -> (sut: WebViewLocalStateStorage, defaults: UserDefaults, persistence: MockPersistenceStorage) { + let persistence = MockPersistenceStorage() + let defaults = UserDefaults(suiteName: testSuiteName)! + defaults.removePersistentDomain(forName: testSuiteName) + let sut = WebViewLocalStateStorage(dataDefaults: defaults, persistenceStorage: persistence) + return (sut, defaults, persistence) + } + + // MARK: - get + + @Test("get returns default version and empty data when storage is empty") + func getEmptyStorage() { + let (sut, _, _) = makeSUT() + + let state = sut.get(keys: []) + + #expect(state.version == Constants.WebViewLocalState.defaultVersion) + #expect(state.data.isEmpty) + } + + @Test("get returns all stored keys when keys array is empty") + func getAllKeys() { + let (sut, defaults, _) = makeSUT() + defaults.set("value1", forKey: "\(keyPrefix)key1") + defaults.set("value2", forKey: "\(keyPrefix)key2") + + let state = sut.get(keys: []) + + #expect(state.data.count == 2) + #expect(state.data["key1"] == "value1") + #expect(state.data["key2"] == "value2") + } + + @Test("get returns only requested keys") + func getSpecificKeys() { + let (sut, defaults, _) = makeSUT() + defaults.set("value1", forKey: "\(keyPrefix)key1") + defaults.set("value2", forKey: "\(keyPrefix)key2") + defaults.set("value3", forKey: "\(keyPrefix)key3") + + let state = sut.get(keys: ["key1", "key3"]) + + #expect(state.data.count == 2) + #expect(state.data["key1"] == "value1") + #expect(state.data["key3"] == "value3") + } + + @Test("get omits missing keys from data") + func getMissingKeys() { + let (sut, defaults, _) = makeSUT() + defaults.set("value1", forKey: "\(keyPrefix)key1") + + let state = sut.get(keys: ["key1", "missing"]) + + #expect(state.data.count == 1) + #expect(state.data["key1"] == "value1") + #expect(state.data["missing"] == nil) + } + + @Test("get returns current version from persistence") + func getCurrentVersion() { + let (sut, _, persistence) = makeSUT() + persistence.webViewLocalStateVersion = 5 + + let state = sut.get(keys: []) + + #expect(state.version == 5) + } + + @Test("get returns default version when persistence version is nil") + func getDefaultVersion() { + let (sut, _, persistence) = makeSUT() + persistence.webViewLocalStateVersion = nil + + let state = sut.get(keys: []) + + #expect(state.version == Constants.WebViewLocalState.defaultVersion) + } + + // MARK: - set + + @Test("set stores values in UserDefaults") + func setStoresValues() { + let (sut, defaults, _) = makeSUT() + + _ = sut.set(data: ["key1": "value1", "key2": "value2"]) + + #expect(defaults.string(forKey: "\(keyPrefix)key1") == "value1") + #expect(defaults.string(forKey: "\(keyPrefix)key2") == "value2") + } + + @Test("set removes key when value is nil") + func setRemovesNilKey() { + let (sut, defaults, _) = makeSUT() + defaults.set("value1", forKey: "\(keyPrefix)key1") + + _ = sut.set(data: ["key1": nil]) + + #expect(defaults.string(forKey: "\(keyPrefix)key1") == nil) + } + + @Test("set updates existing values") + func setUpdatesValues() { + let (sut, defaults, _) = makeSUT() + defaults.set("old", forKey: "\(keyPrefix)key1") + + let state = sut.set(data: ["key1": "new"]) + + #expect(defaults.string(forKey: "\(keyPrefix)key1") == "new") + #expect(state.data["key1"] == "new") + } + + @Test("set returns only affected keys") + func setReturnsAffectedKeys() { + let (sut, defaults, _) = makeSUT() + defaults.set("existing", forKey: "\(keyPrefix)existing") + + let state = sut.set(data: ["key1": "value1"]) + + #expect(state.data.count == 1) + #expect(state.data["key1"] == "value1") + #expect(state.data["existing"] == nil) + } + + @Test("set does not change version") + func setPreservesVersion() { + let (sut, _, persistence) = makeSUT() + persistence.webViewLocalStateVersion = 3 + + let state = sut.set(data: ["key1": "value1"]) + + #expect(state.version == 3) + #expect(persistence.webViewLocalStateVersion == 3) + } + + @Test("set stores each key as separate UserDefaults entry") + func setSeparateEntries() { + let (sut, defaults, _) = makeSUT() + + _ = sut.set(data: ["firstKey": "firstValue", "secondKey": "secondValue"]) + + #expect(defaults.string(forKey: "\(keyPrefix)firstKey") == "firstValue") + #expect(defaults.string(forKey: "\(keyPrefix)secondKey") == "secondValue") + } + + // MARK: - initialize + + @Test("initialize stores version in PersistenceStorage") + func initStoresVersion() { + let (sut, _, persistence) = makeSUT() + + _ = sut.initialize(version: 7, data: ["key": "value"]) + + #expect(persistence.webViewLocalStateVersion == 7) + } + + @Test("initialize stores data and returns it") + func initStoresAndReturnsData() throws { + let (sut, defaults, _) = makeSUT() + + let state = try #require(sut.initialize(version: 2, data: ["key1": "value1", "key2": "value2"])) + + #expect(state.version == 2) + #expect(state.data["key1"] == "value1") + #expect(state.data["key2"] == "value2") + #expect(defaults.string(forKey: "\(keyPrefix)key1") == "value1") + #expect(defaults.string(forKey: "\(keyPrefix)key2") == "value2") + } + + @Test("initialize rejects zero version") + func initRejectsZero() { + let (sut, _, _) = makeSUT() + + #expect(sut.initialize(version: 0, data: ["key": "value"]) == nil) + } + + @Test("initialize rejects negative version") + func initRejectsNegative() { + let (sut, _, _) = makeSUT() + + #expect(sut.initialize(version: -1, data: ["key": "value"]) == nil) + } + + @Test("initialize removes keys with nil values") + func initRemovesNilKeys() { + let (sut, defaults, _) = makeSUT() + defaults.set("value1", forKey: "\(keyPrefix)key1") + + let state = sut.initialize(version: 2, data: ["key1": nil]) + + #expect(state != nil) + #expect(defaults.string(forKey: "\(keyPrefix)key1") == nil) + } + + @Test("initialize merges with existing data") + func initMergesData() { + let (sut, defaults, _) = makeSUT() + defaults.set("existing", forKey: "\(keyPrefix)old") + + let state = sut.initialize(version: 3, data: ["new": "value"]) + + #expect(state != nil) + #expect(defaults.string(forKey: "\(keyPrefix)old") == "existing") + #expect(defaults.string(forKey: "\(keyPrefix)new") == "value") + } + + @Test("initialize does not store version on rejection") + func initPreservesVersionOnReject() { + let (sut, _, persistence) = makeSUT() + persistence.webViewLocalStateVersion = 5 + + _ = sut.initialize(version: 0, data: ["key": "value"]) + + #expect(persistence.webViewLocalStateVersion == 5) + } + + // MARK: - Integration + + @Test("full flow: init → set → get") + func fullFlow() throws { + let (sut, _, _) = makeSUT() + + let initState = try #require(sut.initialize(version: 2, data: ["key1": "value1", "key2": "value2"])) + #expect(initState.version == 2) + + let setState = sut.set(data: ["key1": "updated", "key2": nil, "key3": "value3"]) + #expect(setState.version == 2) + + let getState = sut.get(keys: []) + #expect(getState.version == 2) + #expect(getState.data["key1"] == "updated") + #expect(getState.data["key2"] == nil) + #expect(getState.data["key3"] == "value3") + } + + @Test("get after set with null returns empty for deleted key") + func setNullThenGet() { + let (sut, _, _) = makeSUT() + + _ = sut.set(data: ["key1": "value1"]) + _ = sut.set(data: ["key1": nil]) + + let state = sut.get(keys: ["key1"]) + #expect(state.data.isEmpty) + } + + @Test("prefix isolation: non-prefixed keys and Apple system keys are filtered out") + func prefixIsolation() { + let (sut, defaults, _) = makeSUT() + defaults.set("foreign", forKey: "foreignKey") + defaults.set("value", forKey: "\(keyPrefix)myKey") + + let state = sut.get(keys: []) + + #expect(state.data.count == 1) + #expect(state.data["myKey"] == "value") + #expect(state.data["foreignKey"] == nil) + #expect(state.data["AKLastLocale"] == nil) + #expect(state.data["AppleLocale"] == nil) + #expect(state.data["NSInterfaceStyle"] == nil) + } +} diff --git a/example/app/build.gradle b/example/app/build.gradle index 85e4b4269..1fbb71f6d 100644 --- a/example/app/build.gradle +++ b/example/app/build.gradle @@ -92,7 +92,7 @@ dependencies { implementation 'com.google.code.gson:gson:2.11.0' //Mindbox - implementation 'cloud.mindbox:mobile-sdk:2.14.5' + implementation 'cloud.mindbox:mobile-sdk:2.15.0-rc' implementation 'cloud.mindbox:mindbox-firebase' implementation 'cloud.mindbox:mindbox-huawei' implementation 'cloud.mindbox:mindbox-rustore' diff --git a/gradle.properties b/gradle.properties index d74a443de..c9ffc8477 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.14.5 +SDK_VERSION_NAME=2.15.0-rc USE_LOCAL_MINDBOX_COMMON=true android.nonTransitiveRClass=false kotlin.mpp.androidGradlePluginCompatibility.nowarn=true diff --git a/sdk/src/main/AndroidManifest.xml b/sdk/src/main/AndroidManifest.xml index 822fd693c..e50bafe2f 100644 --- a/sdk/src/main/AndroidManifest.xml +++ b/sdk/src/main/AndroidManifest.xml @@ -5,6 +5,7 @@ + diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/Extensions.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/Extensions.kt index 9780be6a3..ca2530714 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/Extensions.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/Extensions.kt @@ -22,6 +22,7 @@ import cloud.mindbox.mobile_sdk.Mindbox.logE import cloud.mindbox.mobile_sdk.Mindbox.logW import cloud.mindbox.mobile_sdk.inapp.domain.models.InApp import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppType +import cloud.mindbox.mobile_sdk.inapp.domain.models.Layer import cloud.mindbox.mobile_sdk.logger.MindboxLoggerImpl import cloud.mindbox.mobile_sdk.pushes.PushNotificationManager.EXTRA_UNIQ_PUSH_BUTTON_KEY import cloud.mindbox.mobile_sdk.pushes.PushNotificationManager.EXTRA_UNIQ_PUSH_KEY @@ -299,3 +300,19 @@ internal fun List.sortByPriority(): List { internal inline fun Queue.pollIf(predicate: (T) -> Boolean): T? { return peek()?.takeIf(predicate)?.let { poll() } } + +internal fun InAppType.getImageUrl(): String? { + return when (this) { + is InAppType.WebView -> this.layers + is InAppType.ModalWindow -> this.layers + is InAppType.Snackbar -> this.layers + } + .filterIsInstance() + .firstOrNull() + ?.source + ?.let { source -> + when (source) { + is Layer.ImageLayer.Source.UrlSource -> source.url + } + } +} 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 95e4bf48a..9ad7a86e2 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/Mindbox.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/Mindbox.kt @@ -94,6 +94,7 @@ public object Mindbox : MindboxLog { private lateinit var lifecycleManager: LifecycleManager private val userVisitManager: UserVisitManager by mindboxInject { userVisitManager } + private val timeProvider by mindboxInject { timeProvider } internal var pushServiceHandlers: List = listOf() @@ -1244,6 +1245,11 @@ public object Mindbox : MindboxLog { MindboxPreferences.isNotificationEnabled = isNotificationEnabled MindboxPreferences.instanceId = instanceId + if (MindboxPreferences.firstInitializationTime == null) { + MindboxPreferences.firstInitializationTime = timeProvider.currentTimestamp() + .convertToIso8601String() + } + MindboxEventManager.appInstalled(context, initData, configuration.shouldCreateCustomer) deliverDeviceUuid(deviceUuid) @@ -1358,7 +1364,9 @@ public object Mindbox : MindboxLog { requestUrl = requestUrl, sdkVersionNumeric = Constants.SDK_VERSION_NUMERIC ) - + if (source != null || requestUrl != null) { + sessionStorageManager.lastTrackVisitData = trackVisitData + } MindboxEventManager.appStarted(applicationContext, trackVisitData) } } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/DataModule.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/DataModule.kt index 65e570ff2..6695ffa47 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/DataModule.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/DataModule.kt @@ -1,5 +1,6 @@ package cloud.mindbox.mobile_sdk.di.modules +import cloud.mindbox.mobile_sdk.annotations.InternalMindboxApi import cloud.mindbox.mobile_sdk.inapp.data.checkers.MaxInappsPerDayLimitChecker import cloud.mindbox.mobile_sdk.inapp.data.checkers.MaxInappsPerSessionLimitChecker import cloud.mindbox.mobile_sdk.inapp.data.checkers.MinIntervalBetweenShowsLimitChecker @@ -17,7 +18,9 @@ import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.InAppImageLoader import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.InAppImageSizeStorage import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.PermissionManager import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.checkers.Checker +import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.FeatureToggleManager import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.GeoSerializationManager +import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.InAppFailureTracker import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.InAppSerializationManager import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.MobileConfigSerializationManager import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.repositories.* @@ -25,6 +28,7 @@ import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.validators.InAppValidato import cloud.mindbox.mobile_sdk.inapp.presentation.InAppMessageDelayedManager import cloud.mindbox.mobile_sdk.inapp.presentation.MindboxNotificationManager import cloud.mindbox.mobile_sdk.inapp.presentation.MindboxNotificationManagerImpl +import cloud.mindbox.mobile_sdk.inapp.presentation.view.BridgeMessage import cloud.mindbox.mobile_sdk.managers.* import cloud.mindbox.mobile_sdk.managers.MobileConfigSettingsManagerImpl import cloud.mindbox.mobile_sdk.managers.RequestPermissionManager @@ -37,6 +41,7 @@ import com.google.gson.Gson import com.google.gson.GsonBuilder import kotlinx.coroutines.Dispatchers +@OptIn(InternalMindboxApi::class) internal fun DataModule( appContextModule: AppContextModule, apiModule: ApiModule @@ -56,11 +61,14 @@ internal fun DataModule( override val modalWindowValidator: ModalWindowValidator by lazy { ModalWindowValidator( imageLayerValidator = imageLayerValidator, + webViewLayerValidator = webViewLayerValidator, elementValidator = modalElementValidator ) } override val imageLayerValidator: ImageLayerValidator get() = ImageLayerValidator() + override val webViewLayerValidator: WebViewLayerValidator + get() = WebViewLayerValidator() override val modalElementValidator: ModalElementValidator by lazy { ModalElementValidator( @@ -154,7 +162,8 @@ internal fun DataModule( timeSpanPositiveValidator = slidingExpirationParametersValidator, mobileConfigSettingsManager = mobileConfigSettingsManager, integerPositiveValidator = integerPositiveValidator, - inappSettingsManager = inappSettingsManager + inappSettingsManager = inappSettingsManager, + featureToggleManager = featureToggleManager ) } @@ -197,6 +206,14 @@ internal fun DataModule( override val inAppSerializationManager: InAppSerializationManager get() = InAppSerializationManagerImpl(gson = gson) + override val inAppFailureTracker: InAppFailureTracker by lazy { + InAppFailureTrackerImpl( + timeProvider = timeProvider, + inAppRepository = inAppRepository, + featureToggleManager = featureToggleManager + ) + } + override val inAppSegmentationRepository: InAppSegmentationRepository by lazy { InAppSegmentationRepositoryImpl( inAppMapper = inAppMapper, @@ -204,6 +221,11 @@ internal fun DataModule( gatewayManager = gatewayManager, ) } + override val inAppTargetingErrorRepository: InAppTargetingErrorRepository by lazy { + InAppTargetingErrorRepositoryImpl( + sessionStorageManager = sessionStorageManager + ) + } override val monitoringValidator: MonitoringValidator by lazy { MonitoringValidator() } @@ -239,6 +261,7 @@ internal fun DataModule( } override val integerPositiveValidator: IntegerPositiveValidator by lazy { IntegerPositiveValidator() } override val inappSettingsManager: InappSettingsManagerImpl by lazy { InappSettingsManagerImpl(sessionStorageManager) } + override val featureToggleManager: FeatureToggleManager by lazy { FeatureToggleManagerImpl() } override val maxInappsPerSessionLimitChecker: Checker by lazy { MaxInappsPerSessionLimitChecker(sessionStorageManager) } override val maxInappsPerDayLimitChecker: Checker by lazy { MaxInappsPerDayLimitChecker(inAppRepository, sessionStorageManager, timeProvider) } override val minIntervalBetweenShowsLimitChecker: Checker by lazy { MinIntervalBetweenShowsLimitChecker(sessionStorageManager, inAppRepository, timeProvider) } @@ -270,6 +293,23 @@ internal fun DataModule( override val gson: Gson by lazy { GsonBuilder() + .registerTypeAdapterFactory( + RuntimeTypeAdapterFactory + .of( + BridgeMessage::class.java, + BridgeMessage.TYPE_FIELD_NAME, + true + ).registerSubtype( + BridgeMessage.Request::class.java, + BridgeMessage.TYPE_REQUEST + ).registerSubtype( + BridgeMessage.Response::class.java, + BridgeMessage.TYPE_RESPONSE + ).registerSubtype( + BridgeMessage.Error::class.java, + BridgeMessage.TYPE_ERROR + ) + ) .registerTypeAdapterFactory( RuntimeTypeAdapterFactory .of( @@ -295,9 +335,6 @@ internal fun DataModule( ).registerSubtype( PayloadBlankDto.SnackBarBlankDto::class.java, PayloadDto.SnackbarDto.SNACKBAR_JSON_NAME - ).registerSubtype( - PayloadBlankDto.WebViewBlankDto::class.java, - PayloadDto.WebViewDto.WEBVIEW_JSON_NAME ) ).registerTypeAdapterFactory( RuntimeTypeAdapterFactory diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/DomainModule.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/DomainModule.kt index ffd36eab9..a2cf4ba4f 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/DomainModule.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/DomainModule.kt @@ -33,6 +33,7 @@ internal fun DomainModule( maxInappsPerDayLimitChecker = maxInappsPerDayLimitChecker, minIntervalBetweenShowsLimitChecker = minIntervalBetweenShowsLimitChecker, timeProvider = timeProvider, + sessionStorageManager = sessionStorageManager, ) } override val callbackInteractor: CallbackInteractor by lazy { @@ -43,8 +44,10 @@ internal fun DomainModule( InAppProcessingManagerImpl( inAppGeoRepository = inAppGeoRepository, inAppSegmentationRepository = inAppSegmentationRepository, + inAppTargetingErrorRepository = inAppTargetingErrorRepository, inAppContentFetcher = inAppContentFetcher, - inAppRepository = inAppRepository + inAppRepository = inAppRepository, + inAppFailureTracker = inAppFailureTracker ) } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/MindboxModule.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/MindboxModule.kt index 8c2796e29..4d5f84995 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/MindboxModule.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/MindboxModule.kt @@ -69,7 +69,9 @@ internal interface DataModule : MindboxModule { val callbackRepository: CallbackRepository val geoSerializationManager: GeoSerializationManager val inAppSerializationManager: InAppSerializationManager + val inAppFailureTracker: InAppFailureTracker val inAppSegmentationRepository: InAppSegmentationRepository + val inAppTargetingErrorRepository: InAppTargetingErrorRepository val inAppValidator: InAppValidator val inAppMapper: InAppMapper val gson: Gson @@ -88,6 +90,7 @@ internal interface DataModule : MindboxModule { val modalElementDtoDataFiller: ModalElementDtoDataFiller val modalWindowValidator: ModalWindowValidator val imageLayerValidator: ImageLayerValidator + val webViewLayerValidator: WebViewLayerValidator val modalElementValidator: ModalElementValidator val snackbarValidator: SnackbarValidator val closeButtonModalElementValidator: CloseButtonModalElementValidator @@ -114,6 +117,7 @@ internal interface DataModule : MindboxModule { val mobileConfigSettingsManager: MobileConfigSettingsManager val integerPositiveValidator: IntegerPositiveValidator val inappSettingsManager: InappSettingsManager + val featureToggleManager: FeatureToggleManager val maxInappsPerSessionLimitChecker: Checker val maxInappsPerDayLimitChecker: Checker val minIntervalBetweenShowsLimitChecker: Checker diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/PresentationModule.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/PresentationModule.kt index ec8be65c0..de83c9b68 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/PresentationModule.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/PresentationModule.kt @@ -28,7 +28,8 @@ internal fun PresentationModule( monitoringInteractor = monitoringInteractor, sessionStorageManager = sessionStorageManager, userVisitManager = userVisitManager, - inAppMessageDelayedManager = inAppMessageDelayedManager + inAppMessageDelayedManager = inAppMessageDelayedManager, + timeProvider = timeProvider ) } override val clipboardManager: ClipboardManager by lazy { diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/dto/BackgroundDto.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/dto/BackgroundDto.kt index 6898ffa48..81fde851b 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/dto/BackgroundDto.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/dto/BackgroundDto.kt @@ -1,5 +1,7 @@ package cloud.mindbox.mobile_sdk.inapp.data.dto +import cloud.mindbox.mobile_sdk.inapp.data.dto.deserializers.WebViewParamsDeserializer +import com.google.gson.annotations.JsonAdapter import com.google.gson.annotations.SerializedName internal data class BackgroundDto( @@ -68,6 +70,7 @@ internal data class BackgroundDto( @SerializedName("${"$"}type") val type: String?, @SerializedName("params") + @JsonAdapter(WebViewParamsDeserializer::class) val params: Map?, ) : LayerDto() { internal companion object { diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/dto/FormBlankDto.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/dto/FormBlankDto.kt index e92b31539..71fddb49f 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/dto/FormBlankDto.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/dto/FormBlankDto.kt @@ -67,20 +67,6 @@ internal sealed class PayloadBlankDto { val elements: List? ) } - - data class WebViewBlankDto( - @SerializedName("content") - val content: ContentBlankDto?, - @SerializedName("${"$"}type") - val type: String? - ) : PayloadBlankDto() { - internal data class ContentBlankDto( - @SerializedName("background") - val background: BackgroundBlankDto?, - @SerializedName("elements") - val elements: List? - ) - } } internal data class BackgroundBlankDto( diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/dto/PayloadDto.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/dto/PayloadDto.kt index dee31da95..c8b7e5b11 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/dto/PayloadDto.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/dto/PayloadDto.kt @@ -1,6 +1,5 @@ package cloud.mindbox.mobile_sdk.inapp.data.dto -import cloud.mindbox.mobile_sdk.inapp.data.dto.PayloadDto.SnackbarDto.ContentDto import cloud.mindbox.mobile_sdk.isInRange import com.google.gson.annotations.SerializedName @@ -8,18 +7,6 @@ import com.google.gson.annotations.SerializedName * In-app types **/ internal sealed class PayloadDto { - - data class WebViewDto( - @SerializedName("${"$"}type") - val type: String?, - @SerializedName("content") - val content: ModalWindowDto.ContentDto?, - ) : PayloadDto() { - internal companion object { - const val WEBVIEW_JSON_NAME = "webview" - } - } - data class SnackbarDto( @SerializedName("content") val content: ContentDto?, diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/dto/deserializers/FeatureTogglesDtoBlankDeserializer.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/dto/deserializers/FeatureTogglesDtoBlankDeserializer.kt new file mode 100644 index 000000000..7da4678a8 --- /dev/null +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/dto/deserializers/FeatureTogglesDtoBlankDeserializer.kt @@ -0,0 +1,34 @@ +package cloud.mindbox.mobile_sdk.inapp.data.dto.deserializers + +import cloud.mindbox.mobile_sdk.logger.mindboxLogW +import cloud.mindbox.mobile_sdk.models.operation.response.SettingsDtoBlank +import com.google.gson.JsonDeserializationContext +import com.google.gson.JsonDeserializer +import com.google.gson.JsonElement +import java.lang.reflect.Type + +private typealias FeatureTogglesDtoBlank = SettingsDtoBlank.FeatureTogglesDtoBlank + +internal class FeatureTogglesDtoBlankDeserializer : JsonDeserializer { + override fun deserialize( + json: JsonElement, + typeOfT: Type, + context: JsonDeserializationContext + ): FeatureTogglesDtoBlank { + val jsonObject = json.asJsonObject + val result = mutableMapOf() + jsonObject.entrySet().forEach { (key, value) -> + val booleanValue = when { + value?.isJsonPrimitive == true && value.asJsonPrimitive.isBoolean -> + value.asJsonPrimitive.asBoolean + else -> { + mindboxLogW("Feature toggle value is not boolean. key=$key, value=$value") + null + } + } + + result[key] = booleanValue + } + return FeatureTogglesDtoBlank(toggles = result) + } +} diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/dto/deserializers/InAppTagsDeserializer.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/dto/deserializers/InAppTagsDeserializer.kt new file mode 100644 index 000000000..415e6e2f1 --- /dev/null +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/dto/deserializers/InAppTagsDeserializer.kt @@ -0,0 +1,29 @@ +package cloud.mindbox.mobile_sdk.inapp.data.dto.deserializers + +import com.google.gson.JsonDeserializationContext +import com.google.gson.JsonDeserializer +import com.google.gson.JsonElement +import java.lang.reflect.Type + +internal class InAppTagsDeserializer : JsonDeserializer?> { + + override fun deserialize( + json: JsonElement, + typeOfT: Type, + context: JsonDeserializationContext, + ): Map? { + if (json.isJsonNull) return null + if (!json.isJsonObject) return null + return json.asJsonObject.entrySet().mapNotNull { (key, value) -> + if (value.isJsonPrimitive && value.asJsonPrimitive.isString) { + key to value.asString + } else { + null + } + }.toMap() + } + + companion object { + const val TAGS = "tags" + } +} diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/dto/deserializers/JsonElementExtensions.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/dto/deserializers/JsonElementExtensions.kt index 27f6cdbae..e8198e416 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/dto/deserializers/JsonElementExtensions.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/dto/deserializers/JsonElementExtensions.kt @@ -39,3 +39,9 @@ internal fun JsonElement.getString(): String? { else -> null } } + +internal fun JsonObject.getAsBooleanOrNull(key: String): Boolean? { + return get(key)?.takeIf { it.isJsonPrimitive && it.asJsonPrimitive.isBoolean } + ?.asJsonPrimitive + ?.asBoolean +} diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/dto/deserializers/WebViewParamsDeserializer.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/dto/deserializers/WebViewParamsDeserializer.kt new file mode 100644 index 000000000..9decedb76 --- /dev/null +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/dto/deserializers/WebViewParamsDeserializer.kt @@ -0,0 +1,39 @@ +package cloud.mindbox.mobile_sdk.inapp.data.dto.deserializers + +import com.google.gson.Gson +import com.google.gson.JsonDeserializationContext +import com.google.gson.JsonDeserializer +import com.google.gson.JsonElement +import java.lang.reflect.Type + +internal class WebViewParamsDeserializer : JsonDeserializer?> { + + override fun deserialize( + json: JsonElement, + typeOfT: Type, + context: JsonDeserializationContext + ): Map? { + if (json.isJsonNull) return null + if (!json.isJsonObject) return emptyMap() + return json.asJsonObject.entrySet().mapNotNull { (key, value) -> + value.toParamString()?.let { key to it } + }.toMap() + } + + private fun JsonElement.toParamString(): String? { + if (isJsonNull) return null + return when { + isJsonPrimitive -> when { + asJsonPrimitive.isString -> asString + asJsonPrimitive.isNumber -> asNumber.toString() + asJsonPrimitive.isBoolean -> asBoolean.toString() + else -> asString + } + else -> GSON.toJson(this) + } + } + + private companion object { + private val GSON = Gson() + } +} diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/FeatureToggleManagerImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/FeatureToggleManagerImpl.kt new file mode 100644 index 000000000..3407125cb --- /dev/null +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/FeatureToggleManagerImpl.kt @@ -0,0 +1,25 @@ +package cloud.mindbox.mobile_sdk.inapp.data.managers + +import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.FeatureToggleManager +import cloud.mindbox.mobile_sdk.models.operation.response.InAppConfigResponse +import java.util.concurrent.ConcurrentHashMap + +internal const val SEND_INAPP_SHOW_ERROR_FEATURE = "MobileSdkShouldSendInAppShowError" + +internal class FeatureToggleManagerImpl : FeatureToggleManager { + + private val toggles = ConcurrentHashMap() + + override fun applyToggles(config: InAppConfigResponse?) { + toggles.clear() + config?.settings?.featureToggles?.forEach { (key, value) -> + value?.let { + toggles[key] = value + } + } + } + + override fun isEnabled(key: String): Boolean { + return toggles[key] ?: true + } +} diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppFailureTrackerImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppFailureTrackerImpl.kt new file mode 100644 index 000000000..8b9fd6a3b --- /dev/null +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppFailureTrackerImpl.kt @@ -0,0 +1,87 @@ +package cloud.mindbox.mobile_sdk.inapp.data.managers + +import cloud.mindbox.mobile_sdk.convertToString +import cloud.mindbox.mobile_sdk.convertToZonedDateTimeAtUTC +import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.FeatureToggleManager +import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.InAppFailureTracker +import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.repositories.InAppRepository +import cloud.mindbox.mobile_sdk.logger.mindboxLogI +import cloud.mindbox.mobile_sdk.models.operation.request.FailureReason +import cloud.mindbox.mobile_sdk.models.operation.request.InAppShowFailure +import cloud.mindbox.mobile_sdk.utils.TimeProvider +import org.threeten.bp.Instant +import java.util.concurrent.CopyOnWriteArrayList + +internal class InAppFailureTrackerImpl( + private val timeProvider: TimeProvider, + private val inAppRepository: InAppRepository, + private val featureToggleManager: FeatureToggleManager +) : InAppFailureTracker { + + private val failures = CopyOnWriteArrayList() + + private fun trackFailure(failure: InAppShowFailure) { + if (failures.none { it.inAppId == failure.inAppId }) { + failures.add(failure) + } + } + + private fun sendFailures() { + if (failures.isEmpty()) return + if (!featureToggleManager.isEnabled(SEND_INAPP_SHOW_ERROR_FEATURE)) { + mindboxLogI("Feature $SEND_INAPP_SHOW_ERROR_FEATURE is off. Skip send failures") + return + } + inAppRepository.sendInAppShowFailure(failures.toList()) + failures.clear() + } + + private fun sendSingleFailure(failure: InAppShowFailure) { + if (!featureToggleManager.isEnabled(SEND_INAPP_SHOW_ERROR_FEATURE)) { + mindboxLogI("Feature $SEND_INAPP_SHOW_ERROR_FEATURE is off. Skip send failure") + return + } + inAppRepository.sendInAppShowFailure(listOf(failure)) + } + + override fun sendFailure(inAppId: String, failureReason: FailureReason, errorDetails: String?) { + val timestamp = Instant.ofEpochMilli(timeProvider.currentTimeMillis()) + .convertToZonedDateTimeAtUTC() + .convertToString() + + sendSingleFailure( + failure = InAppShowFailure( + inAppId = inAppId, + failureReason = failureReason, + errorDetails = errorDetails?.take(COUNT_OF_CHARS_IN_ERROR_DETAILS), + dateTimeUtc = timestamp + ) + ) + } + + override fun collectFailure(inAppId: String, failureReason: FailureReason, errorDetails: String?) { + val timestamp = Instant.ofEpochMilli(timeProvider.currentTimeMillis()) + .convertToZonedDateTimeAtUTC() + .convertToString() + trackFailure( + InAppShowFailure( + inAppId = inAppId, + failureReason = failureReason, + errorDetails = errorDetails?.take(COUNT_OF_CHARS_IN_ERROR_DETAILS), + dateTimeUtc = timestamp + ) + ) + } + + override fun sendCollectedFailures() { + sendFailures() + } + + override fun clearFailures() { + failures.clear() + } + + companion object { + private const val COUNT_OF_CHARS_IN_ERROR_DETAILS = 1000 + } +} diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppSerializationManagerImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppSerializationManagerImpl.kt index d695adc72..d80cff2c1 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppSerializationManagerImpl.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppSerializationManagerImpl.kt @@ -2,7 +2,10 @@ package cloud.mindbox.mobile_sdk.inapp.data.managers import cloud.mindbox.mobile_sdk.fromJsonTyped import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.InAppSerializationManager +import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppFailuresWrapper import cloud.mindbox.mobile_sdk.models.operation.request.InAppHandleRequest +import cloud.mindbox.mobile_sdk.models.operation.request.InAppShowRequest +import cloud.mindbox.mobile_sdk.models.operation.request.InAppShowFailure import cloud.mindbox.mobile_sdk.toJsonTyped import cloud.mindbox.mobile_sdk.utils.LoggingExceptionHandler import cloud.mindbox.mobile_sdk.utils.loggingRunCatching @@ -11,9 +14,25 @@ import com.google.gson.reflect.TypeToken internal class InAppSerializationManagerImpl(private val gson: Gson) : InAppSerializationManager { - override fun serializeToInAppHandledString(inAppId: String): String { - return LoggingExceptionHandler.runCatching("") { - gson.toJson(InAppHandleRequest(inAppId), InAppHandleRequest::class.java) + override fun serializeToInAppShownActionString( + inAppId: String, + timeToDisplay: String, + tags: Map?, + ): String { + return loggingRunCatching("") { + gson.toJsonTyped( + InAppShowRequest( + inAppId = inAppId, + timeToDisplay = timeToDisplay, + tags = tags, + ) + ) + } + } + + override fun serializeToInAppActionString(inAppId: String): String { + return loggingRunCatching("") { + gson.toJsonTyped(InAppHandleRequest(inAppId = inAppId)) } } @@ -23,6 +42,14 @@ internal class InAppSerializationManagerImpl(private val gson: Gson) : InAppSeri } } + override fun serializeToInAppShowFailuresString( + inAppShowFailures: List + ): String { + return loggingRunCatching("") { + gson.toJsonTyped(InAppFailuresWrapper(inAppShowFailures)) + } + } + override fun deserializeToShownInAppsMap(shownInApps: String): Map> { return loggingRunCatching(hashMapOf()) { gson.fromJsonTyped>>(shownInApps) ?: hashMapOf() 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 b9d9bfc3b..2dd127f13 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 @@ -110,12 +110,18 @@ internal class MobileConfigSerializationManagerImpl(private val gson: Gson) : } val inappSettings = runCatching { - gson.fromJson(json.asJsonObject.get("inapp"), SettingsDtoBlank.InappSettingsDtoBlank::class.java)?.copy() + gson.fromJson(json.asJsonObject.get("inapp"), InappSettingsDtoBlank::class.java)?.copy() }.getOrNull { mindboxLogE("Failed to parse inapp block in settings section ") } - SettingsDtoBlank(operations, ttl, slidingExpiration, inappSettings) + val featureToggles = runCatching { + gson.fromJson(json.asJsonObject.get("featureToggles"), FeatureTogglesDtoBlank::class.java)?.copy() + }.getOrNull { + mindboxLogE("Failed to parse featureToggles block in settings section") + } + + SettingsDtoBlank(operations, ttl, slidingExpiration, inappSettings, featureToggles) } }.getOrNull { mindboxLogE("Failed to parse settings block", it) @@ -145,19 +151,6 @@ internal class MobileConfigSerializationManagerImpl(private val gson: Gson) : variants = blankResult.getOrNull()?.variants?.filterNotNull() ?.map { payloadBlankDto -> when (payloadBlankDto) { - is PayloadBlankDto.WebViewBlankDto -> { - PayloadDto.WebViewDto( - content = PayloadDto.ModalWindowDto.ContentDto( - background = BackgroundDto( - layers = payloadBlankDto.content?.background?.layers?.mapNotNull { - deserializeToBackgroundLayersDto(it as JsonObject) - }), - elements = payloadBlankDto.content?.elements?.mapNotNull { - deserializeToElementDto(it) - } - ), type = PayloadDto.WebViewDto.WEBVIEW_JSON_NAME - ) - } is PayloadBlankDto.ModalWindowBlankDto -> { PayloadDto.ModalWindowDto( content = PayloadDto.ModalWindowDto.ContentDto( @@ -171,6 +164,7 @@ internal class MobileConfigSerializationManagerImpl(private val gson: Gson) : ), type = PayloadDto.ModalWindowDto.MODAL_JSON_NAME ) } + is PayloadBlankDto.SnackBarBlankDto -> { PayloadDto.SnackbarDto( content = PayloadDto.SnackbarDto.ContentDto( @@ -193,7 +187,7 @@ internal class MobileConfigSerializationManagerImpl(private val gson: Gson) : top = payloadBlankDto.content?.position?.margin?.top ) ) - ), type = PayloadDto.ModalWindowDto.MODAL_JSON_NAME + ), type = PayloadDto.SnackbarDto.SNACKBAR_JSON_NAME ) } } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/PermissionManagerImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/PermissionManagerImpl.kt index 8f26a2503..7e2209a16 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/PermissionManagerImpl.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/PermissionManagerImpl.kt @@ -1,17 +1,101 @@ package cloud.mindbox.mobile_sdk.inapp.data.managers +import android.Manifest import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import androidx.core.content.ContextCompat import androidx.core.app.NotificationManagerCompat import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.PermissionManager +import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.PermissionStatus import cloud.mindbox.mobile_sdk.logger.mindboxLogE internal class PermissionManagerImpl(private val context: Context) : PermissionManager { - override fun isNotificationEnabled(): Boolean { + + override fun getCameraPermissionStatus(): PermissionStatus { + return runCatching { + resolveRuntimePermissionStatus(Manifest.permission.CAMERA) + }.getOrElse { _ -> + mindboxLogE("Unknown error checking camera permission status") + PermissionStatus.NOT_DETERMINED + } + } + + override fun getLocationPermissionStatus(): PermissionStatus { return runCatching { - NotificationManagerCompat.from(context).areNotificationsEnabled() + val fineStatus: PermissionStatus = resolveRuntimePermissionStatus(Manifest.permission.ACCESS_FINE_LOCATION) + val coarseStatus: PermissionStatus = resolveRuntimePermissionStatus(Manifest.permission.ACCESS_COARSE_LOCATION) + when { + fineStatus == PermissionStatus.GRANTED || coarseStatus == PermissionStatus.GRANTED -> PermissionStatus.GRANTED + else -> PermissionStatus.DENIED + } + }.getOrElse { _ -> + mindboxLogE("Unknown error checking location permission status") + PermissionStatus.NOT_DETERMINED + } + } + + override fun getMicrophonePermissionStatus(): PermissionStatus { + return runCatching { + resolveRuntimePermissionStatus(Manifest.permission.RECORD_AUDIO) + }.getOrElse { _ -> + mindboxLogE("Unknown error checking microphone permission status") + PermissionStatus.NOT_DETERMINED + } + } + + override fun getNotificationPermissionStatus(): PermissionStatus { + return runCatching { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + val runtimeStatus: PermissionStatus = resolveRuntimePermissionStatus(Manifest.permission.POST_NOTIFICATIONS) + val areNotificationsEnabled: Boolean = NotificationManagerCompat.from(context).areNotificationsEnabled() + if (runtimeStatus == PermissionStatus.GRANTED && areNotificationsEnabled) { + PermissionStatus.GRANTED + } else { + PermissionStatus.DENIED + } + } else { + if (NotificationManagerCompat.from(context).areNotificationsEnabled()) { + PermissionStatus.GRANTED + } else { + PermissionStatus.DENIED + } + } }.getOrElse { _ -> mindboxLogE("Unknown error checking notification permission status") - true + PermissionStatus.NOT_DETERMINED } } + + override fun getPhotoLibraryPermissionStatus(): PermissionStatus { + return runCatching { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + val imagesStatus: PermissionStatus = resolveRuntimePermissionStatus(Manifest.permission.READ_MEDIA_IMAGES) + if (imagesStatus == PermissionStatus.GRANTED) { + return@runCatching PermissionStatus.GRANTED + } + val selectedPhotosGranted: Boolean = ContextCompat.checkSelfPermission( + context, + Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED + ) == PackageManager.PERMISSION_GRANTED + if (selectedPhotosGranted) { + PermissionStatus.LIMITED + } else { + imagesStatus + } + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + resolveRuntimePermissionStatus(Manifest.permission.READ_MEDIA_IMAGES) + } else { + resolveRuntimePermissionStatus(Manifest.permission.READ_EXTERNAL_STORAGE) + } + }.getOrElse { _ -> + mindboxLogE("Unknown error checking photo library permission status") + PermissionStatus.NOT_DETERMINED + } + } + + private fun resolveRuntimePermissionStatus(permission: String): PermissionStatus { + val isGranted: Boolean = ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED + return if (isGranted) PermissionStatus.GRANTED else PermissionStatus.DENIED + } } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/SessionStorageManager.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/SessionStorageManager.kt index 7cbc4491e..4a3c71418 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/SessionStorageManager.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/SessionStorageManager.kt @@ -2,6 +2,8 @@ package cloud.mindbox.mobile_sdk.inapp.data.managers import cloud.mindbox.mobile_sdk.inapp.domain.models.* import cloud.mindbox.mobile_sdk.logger.mindboxLogI +import cloud.mindbox.mobile_sdk.models.InAppEventType +import cloud.mindbox.mobile_sdk.models.TrackVisitData import cloud.mindbox.mobile_sdk.utils.TimeProvider import cloud.mindbox.mobile_sdk.utils.loggingRunCatching import java.util.concurrent.atomic.AtomicLong @@ -22,11 +24,14 @@ internal class SessionStorageManager(private val timeProvider: TimeProvider) { var inAppProductSegmentations: HashMap, Set> = HashMap() var processedProductSegmentations: MutableMap, ProductSegmentationFetchStatus> = mutableMapOf() + var lastTargetingErrors: MutableMap = mutableMapOf() var currentSessionInApps: List = emptyList() var shownInAppIdsWithEvents = mutableMapOf>() var configFetchingError: Boolean = false var sessionTime: Duration = 0L.milliseconds var inAppShowLimitsSettings: InAppShowLimitsSettings = InAppShowLimitsSettings() + var lastTrackVisitData: TrackVisitData? = null + var inAppTriggerEvent: InAppEventType? = null val lastTrackVisitSendTime: AtomicLong = AtomicLong(0L) @@ -75,11 +80,13 @@ internal class SessionStorageManager(private val timeProvider: TimeProvider) { geoFetchStatus = GeoFetchStatus.GEO_NOT_FETCHED inAppProductSegmentations.clear() processedProductSegmentations.clear() + lastTargetingErrors.clear() currentSessionInApps = emptyList() shownInAppIdsWithEvents.clear() configFetchingError = false sessionTime = 0L.milliseconds inAppShowLimitsSettings = InAppShowLimitsSettings() + inAppTriggerEvent = null } private fun notifySessionExpired() { diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/data_filler/DataManager.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/data_filler/DataManager.kt index 047df3c26..870a79d68 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/data_filler/DataManager.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/data_filler/DataManager.kt @@ -11,13 +11,6 @@ internal class DataManager( ) { fun fillFormData(item: FormDto?): FormDto? = item?.copy(variants = item.variants?.filterNotNull()?.map { payloadDto -> when (payloadDto) { - is PayloadDto.WebViewDto -> { - payloadDto.copy( - content = payloadDto.content, - type = PayloadDto.ModalWindowDto.MODAL_JSON_NAME - ) - } - is PayloadDto.ModalWindowDto -> { modalWindowDtoDataFiller.fillData(payloadDto) } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/mapper/InAppMapper.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/mapper/InAppMapper.kt index be7b5bda1..35715f30c 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/mapper/InAppMapper.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/mapper/InAppMapper.kt @@ -62,7 +62,8 @@ internal class InAppMapper { sdkVersion = inApp.sdkVersion, targeting = targetingDto, frequency = frequencyDto, - form = formDto + form = formDto, + tags = inApp.tags, ) } } @@ -78,7 +79,7 @@ internal class InAppMapper { ) } - private fun mapModalWindowLayers(layers: List?): List { + private fun mapBackgroundLayers(layers: List?): List { return layers?.map { layerDto -> when (layerDto) { is BackgroundDto.LayerDto.ImageLayerDto -> { @@ -244,28 +245,28 @@ internal class InAppMapper { form = Form( variants = inAppDto.form?.variants?.map { payloadDto -> when (payloadDto) { - is PayloadDto.WebViewDto -> { - InAppType.WebView( - inAppId = inAppDto.id, - type = PayloadDto.WebViewDto.WEBVIEW_JSON_NAME, - layers = mapModalWindowLayers(payloadDto.content?.background?.layers), - ) - } - is PayloadDto.ModalWindowDto -> { - InAppType.ModalWindow( - type = PayloadDto.ModalWindowDto.MODAL_JSON_NAME, - layers = mapModalWindowLayers(payloadDto.content?.background?.layers), - inAppId = inAppDto.id, - elements = mapElements(payloadDto.content?.elements) - ) + val layers = mapBackgroundLayers(payloadDto.content?.background?.layers) + when (layers.firstOrNull()) { + is Layer.WebViewLayer -> InAppType.WebView( + inAppId = inAppDto.id, + type = BackgroundDto.LayerDto.WebViewLayerDto.WEBVIEW_TYPE_JSON_NAME, + layers = layers, + ) + else -> InAppType.ModalWindow( + type = PayloadDto.ModalWindowDto.MODAL_JSON_NAME, + layers = layers, + inAppId = inAppDto.id, + elements = mapElements(payloadDto.content?.elements) + ) + } } is PayloadDto.SnackbarDto -> { InAppType.Snackbar( inAppId = inAppDto.id, type = PayloadDto.SnackbarDto.SNACKBAR_JSON_NAME, - layers = mapModalWindowLayers(payloadDto.content?.background?.layers), + layers = mapBackgroundLayers(payloadDto.content?.background?.layers), elements = mapElements(payloadDto.content?.elements), position = InAppType.Snackbar.Position( gravity = InAppType.Snackbar.Position.Gravity( @@ -303,7 +304,8 @@ internal class InAppMapper { ), minVersion = inAppDto.sdkVersion?.minVersion, maxVersion = inAppDto.sdkVersion?.maxVersion, - frequency = Frequency(getDelay(inAppDto.frequency)) + frequency = Frequency(getDelay(inAppDto.frequency)), + tags = inAppDto.tags?.takeIf { it.isNotEmpty() } ) } ?: emptyList(), monitoring = inAppConfigResponse.monitoring?.map { diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppRepositoryImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppRepositoryImpl.kt index 307038365..1e0ad72a3 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppRepositoryImpl.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppRepositoryImpl.kt @@ -10,6 +10,7 @@ import cloud.mindbox.mobile_sdk.logger.mindboxLogI import cloud.mindbox.mobile_sdk.managers.MindboxEventManager import cloud.mindbox.mobile_sdk.models.InAppEventType import cloud.mindbox.mobile_sdk.models.Timestamp +import cloud.mindbox.mobile_sdk.models.operation.request.InAppShowFailure import cloud.mindbox.mobile_sdk.repository.MindboxPreferences import cloud.mindbox.mobile_sdk.utils.SystemTimeProvider import kotlinx.coroutines.flow.Flow @@ -97,8 +98,8 @@ internal class InAppRepositoryImpl( mindboxLogI("Increase count of shown inapp per day") } - override fun sendInAppShown(inAppId: String) { - inAppSerializationManager.serializeToInAppHandledString(inAppId).apply { + override fun sendInAppShown(inAppId: String, timeToDisplay: String, tags: Map?) { + inAppSerializationManager.serializeToInAppShownActionString(inAppId, timeToDisplay, tags).apply { if (isNotBlank()) { MindboxEventManager.inAppShown( context, @@ -109,7 +110,7 @@ internal class InAppRepositoryImpl( } override fun sendInAppClicked(inAppId: String) { - inAppSerializationManager.serializeToInAppHandledString(inAppId).apply { + inAppSerializationManager.serializeToInAppActionString(inAppId).apply { if (isNotBlank()) { MindboxEventManager.inAppClicked( context, @@ -120,7 +121,7 @@ internal class InAppRepositoryImpl( } override fun sendUserTargeted(inAppId: String) { - inAppSerializationManager.serializeToInAppHandledString(inAppId).apply { + inAppSerializationManager.serializeToInAppActionString(inAppId).apply { if (isNotBlank()) { MindboxEventManager.sendUserTargeted( context, @@ -130,6 +131,18 @@ internal class InAppRepositoryImpl( } } + override fun sendInAppShowFailure(failures: List) { + failures + .takeIf { it.isNotEmpty() } + ?.let { + inAppSerializationManager.serializeToInAppShowFailuresString(failures) + .takeIf { it.isNotBlank() } + ?.let { operationBody -> + MindboxEventManager.inAppShowFailure(context, operationBody) + } + } + } + override fun isInAppShown(inAppId: String): Boolean { return sessionStorageManager.inAppMessageShownInSession.any { it == inAppId } } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppTargetingErrorRepositoryImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppTargetingErrorRepositoryImpl.kt new file mode 100644 index 000000000..a679604fb --- /dev/null +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppTargetingErrorRepositoryImpl.kt @@ -0,0 +1,22 @@ +package cloud.mindbox.mobile_sdk.inapp.data.repositories + +import cloud.mindbox.mobile_sdk.inapp.data.managers.SessionStorageManager +import cloud.mindbox.mobile_sdk.inapp.domain.extensions.getVolleyErrorDetails +import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.repositories.InAppTargetingErrorRepository +import cloud.mindbox.mobile_sdk.inapp.domain.models.TargetingErrorKey + +internal class InAppTargetingErrorRepositoryImpl( + private val sessionStorageManager: SessionStorageManager, +) : InAppTargetingErrorRepository { + override fun saveError(key: TargetingErrorKey, error: Throwable) { + sessionStorageManager.lastTargetingErrors[key] = "${error.message}. ${error.cause?.getVolleyErrorDetails() ?: "volleyError = null"}" + } + + override fun getError(key: TargetingErrorKey): String? { + return sessionStorageManager.lastTargetingErrors[key] + } + + override fun clearErrors() { + sessionStorageManager.lastTargetingErrors.clear() + } +} 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 34a864701..097abc998 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 @@ -7,6 +7,7 @@ import cloud.mindbox.mobile_sdk.inapp.data.managers.SessionStorageManager import cloud.mindbox.mobile_sdk.inapp.data.managers.data_filler.DataManager import cloud.mindbox.mobile_sdk.inapp.data.mapper.InAppMapper import cloud.mindbox.mobile_sdk.inapp.data.validators.* +import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.FeatureToggleManager import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.MobileConfigSerializationManager import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.repositories.MobileConfigRepository import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.validators.InAppValidator @@ -49,7 +50,8 @@ internal class MobileConfigRepositoryImpl( private val timeSpanPositiveValidator: TimeSpanPositiveValidator, private val mobileConfigSettingsManager: MobileConfigSettingsManager, private val integerPositiveValidator: IntegerPositiveValidator, - private val inappSettingsManager: InappSettingsManager + private val inappSettingsManager: InappSettingsManager, + private val featureToggleManager: FeatureToggleManager ) : MobileConfigRepository { private val mutex = Mutex() @@ -100,6 +102,7 @@ internal class MobileConfigRepositoryImpl( mobileConfigSettingsManager.saveSessionTime(config = filteredConfig) mobileConfigSettingsManager.checkPushTokenKeepalive(config = filteredConfig) inappSettingsManager.applySettings(config = filteredConfig) + featureToggleManager.applyToggles(config = filteredConfig) configState.value = updatedInAppConfig mindboxLogI(message = "Providing config: $updatedInAppConfig") } @@ -182,7 +185,12 @@ internal class MobileConfigRepositoryImpl( val inappSettings = runCatching { getInappSettings(configBlank) }.getOrNull { mindboxLogW("Unable to get inapp settings $it") } - return SettingsDto(operations, ttl, slidingExpiration, inappSettings) + + val featureToggles = runCatching { getFeatureToggles(configBlank) }.getOrNull { + mindboxLogW("Unable to get featureToggles settings $it") + } + + return SettingsDto(operations, ttl, slidingExpiration, inappSettings, featureToggles) } private fun getInAppTtl(configBlank: InAppConfigResponseBlank?): TtlDto? = @@ -241,6 +249,9 @@ internal class MobileConfigRepositoryImpl( null } + private fun getFeatureToggles(configBlank: InAppConfigResponseBlank?): Map? = + configBlank?.settings?.featureToggles?.toggles + private fun getABTests(configBlank: InAppConfigResponseBlank?): List { return try { if (configBlank?.abtests == null) return listOf() diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/validators/BridgeMessageValidator.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/validators/BridgeMessageValidator.kt new file mode 100644 index 000000000..60c24b513 --- /dev/null +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/validators/BridgeMessageValidator.kt @@ -0,0 +1,49 @@ +package cloud.mindbox.mobile_sdk.inapp.data.validators + +import cloud.mindbox.mobile_sdk.annotations.InternalMindboxApi +import cloud.mindbox.mobile_sdk.inapp.presentation.view.BridgeMessage +import cloud.mindbox.mobile_sdk.logger.mindboxLogW + +@OptIn(InternalMindboxApi::class) +internal class BridgeMessageValidator : Validator { + override fun isValid(item: BridgeMessage?): Boolean { + item ?: return false + + runCatching { + if (item.id.isBlank()) { + mindboxLogW("BridgeMessage id is empty") + return false + } + + if (item.type !in listOf( + BridgeMessage.TYPE_REQUEST, + BridgeMessage.TYPE_RESPONSE, + BridgeMessage.TYPE_ERROR + ) + ) { + mindboxLogW("BridgeMessage type ${item.type} is not supported") + return false + } + + if (item.action.name.isEmpty()) { + mindboxLogW("BridgeMessage action is empty") + return false + } + + if (item.timestamp <= 0L) { + mindboxLogW("BridgeMessage timestamp must be positive") + return false + } + + if (item.version > BridgeMessage.VERSION) { + mindboxLogW("BridgeMessage version ${item.version} is not supported") + return false + } + }.onFailure { error -> + mindboxLogW("BridgeMessage validation error: $error") + return false + } + + return true + } +} diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/validators/HapticRequestValidator.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/validators/HapticRequestValidator.kt new file mode 100644 index 000000000..c25bc6780 --- /dev/null +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/validators/HapticRequestValidator.kt @@ -0,0 +1,68 @@ +package cloud.mindbox.mobile_sdk.inapp.data.validators + +import cloud.mindbox.mobile_sdk.inapp.presentation.view.HapticConstants +import cloud.mindbox.mobile_sdk.inapp.presentation.view.HapticPatternEvent +import cloud.mindbox.mobile_sdk.inapp.presentation.view.HapticRequest +import cloud.mindbox.mobile_sdk.logger.mindboxLogW + +internal class HapticRequestValidator : Validator { + + override fun isValid(item: HapticRequest): Boolean = when (item) { + is HapticRequest.Selection -> true + is HapticRequest.Impact -> true + is HapticRequest.Pattern -> isValidPattern(item.events) + } + + private fun isValidPattern(events: List): Boolean { + if (events.isEmpty()) return logAndFail("pattern is empty") + if (events.size > MAX_EVENTS) return logAndFail("too many events: ${events.size}") + if (!events.all { isValidEvent(it) }) return false + return isValidPatternOrder(events.sortedBy { it.time }) + } + + private fun isValidEvent(event: HapticPatternEvent): Boolean { + if (event.time !in 0L..MAX_TOTAL_DURATION_MS) { + return logAndFail("event time out of range: ${event.time}") + } + if (event.duration !in 0L..MAX_SINGLE_EVENT_DURATION_MS) { + return logAndFail("event duration out of range: ${event.duration}") + } + if (event.intensity !in 0f..1f) { + return logAndFail("event intensity out of range: ${event.intensity}") + } + if (event.sharpness !in 0f..1f) { + return logAndFail("event sharpness out of range: ${event.sharpness}") + } + val effectiveDuration: Long = effectiveDurationOf(event) + if (event.time + effectiveDuration > MAX_TOTAL_DURATION_MS) { + return logAndFail("event time + effectiveDuration exceeds max: ${event.time + effectiveDuration}") + } + return true + } + + private fun isValidPatternOrder(sortedEvents: List): Boolean { + for (i in 1 until sortedEvents.size) { + val previous: HapticPatternEvent = sortedEvents[i - 1] + val next: HapticPatternEvent = sortedEvents[i] + val previousEnd: Long = previous.time + effectiveDurationOf(previous) + if (next.time < previousEnd) { + return logAndFail("event at time=${next.time} overlaps previous event ending at $previousEnd") + } + } + return true + } + + private fun effectiveDurationOf(event: HapticPatternEvent): Long = + if (event.duration > 0) event.duration else HapticConstants.TRANSIENT_DURATION_MS + + private fun logAndFail(reason: String): Boolean { + mindboxLogW("[Haptic] invalid pattern: $reason") + return false + } + + private companion object { + const val MAX_EVENTS = 128 + const val MAX_TOTAL_DURATION_MS = 30_000L + const val MAX_SINGLE_EVENT_DURATION_MS = 5_000L + } +} diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/validators/ModalWindowValidator.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/validators/ModalWindowValidator.kt index 39388a38a..1e5f83d94 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/validators/ModalWindowValidator.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/validators/ModalWindowValidator.kt @@ -6,6 +6,7 @@ import cloud.mindbox.mobile_sdk.logger.mindboxLogI internal class ModalWindowValidator( private val imageLayerValidator: ImageLayerValidator, + private val webViewLayerValidator: WebViewLayerValidator, private val elementValidator: ModalElementValidator ) : Validator { @@ -27,7 +28,11 @@ internal class ModalWindowValidator( mindboxLogI("Finish checking image layer and it's validity = $rez") !rez } - else -> false + is BackgroundDto.LayerDto.WebViewLayerDto -> { + val rez = webViewLayerValidator.isValid(layerDto) + mindboxLogI("Finish checking webview layer and it's validity = $rez") + !rez + } } } if (invalidLayer != null) { diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/validators/SnackbarValidator.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/validators/SnackbarValidator.kt index fc8669c75..3b70df261 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/validators/SnackbarValidator.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/validators/SnackbarValidator.kt @@ -2,7 +2,7 @@ package cloud.mindbox.mobile_sdk.inapp.data.validators import cloud.mindbox.mobile_sdk.inapp.data.dto.BackgroundDto import cloud.mindbox.mobile_sdk.inapp.data.dto.PayloadDto -import cloud.mindbox.mobile_sdk.logger.mindboxLogD +import cloud.mindbox.mobile_sdk.logger.mindboxLogI internal class SnackbarValidator( private val imageLayerValidator: ImageLayerValidator, @@ -10,41 +10,44 @@ internal class SnackbarValidator( ) : Validator { override fun isValid(item: PayloadDto.SnackbarDto?): Boolean { if (item?.type != PayloadDto.SnackbarDto.SNACKBAR_JSON_NAME) { - mindboxLogD("InApp is not valid. Expected type is ${PayloadDto.SnackbarDto.SNACKBAR_JSON_NAME}. Actual type = ${item?.type}") + mindboxLogI("InApp is not valid. Expected type is ${PayloadDto.SnackbarDto.SNACKBAR_JSON_NAME}. Actual type = ${item?.type}") return false } val layers = item.content?.background?.layers?.filterNotNull() if (layers.isNullOrEmpty()) { - mindboxLogD("InApp is not valid. Layers should not be empty. Layers are = $layers") + mindboxLogI("InApp is not valid. Layers should not be empty. Layers are = $layers") return false } val invalidLayer = layers.find { layerDto -> when (layerDto) { is BackgroundDto.LayerDto.ImageLayerDto -> { - mindboxLogD("Start checking image layer") + mindboxLogI("Start checking image layer") val rez = imageLayerValidator.isValid(layerDto) - mindboxLogD("Finish checking image layer and it's validity = $rez") + mindboxLogI("Finish checking image layer and it's validity = $rez") !rez } - else -> false + else -> { + mindboxLogI("InApp is not valid. Snackbar supports only image layer, got ${layerDto.javaClass.simpleName}") + true + } } } if (invalidLayer != null) { - mindboxLogD("InApp is not valid. At least one layer is invalid") + mindboxLogI("InApp is not valid. At least one layer is invalid") return false } val isValidMargin = item.content.position.margin.isValidPosition() if (!isValidMargin) { - mindboxLogD("InApp has invalid margin") + mindboxLogI("InApp has invalid margin") return false } item.content.elements?.forEach { elementDto -> if (!elementValidator.isValid(elementDto)) { - mindboxLogD("InApp is not valid. At least one element is invalid") + mindboxLogI("InApp is not valid. At least one element is invalid") return false } } - mindboxLogD("Current inApp payload is valid") + mindboxLogI("Current inApp payload is valid") return true } } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/validators/WebViewLayerValidator.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/validators/WebViewLayerValidator.kt new file mode 100644 index 000000000..b1c347942 --- /dev/null +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/validators/WebViewLayerValidator.kt @@ -0,0 +1,36 @@ +package cloud.mindbox.mobile_sdk.inapp.data.validators + +import cloud.mindbox.mobile_sdk.inapp.data.dto.BackgroundDto +import cloud.mindbox.mobile_sdk.logger.mindboxLogW + +internal class WebViewLayerValidator : Validator { + + override fun isValid(item: BackgroundDto.LayerDto.WebViewLayerDto?): Boolean { + if (item == null) { + mindboxLogW("InApp is invalid. WebView layer is null") + return false + } + if (item.type != BackgroundDto.LayerDto.WebViewLayerDto.WEBVIEW_TYPE_JSON_NAME) { + mindboxLogW( + "InApp is invalid. WebView layer is expected to have type = ${BackgroundDto.LayerDto.WebViewLayerDto.WEBVIEW_TYPE_JSON_NAME}. " + + "Actual type = ${item.type}" + ) + return false + } + if (item.baseUrl.isNullOrBlank()) { + mindboxLogW( + "InApp is invalid. WebView layer is expected to have non-blank baseUrl. " + + "Actual baseUrl = ${item.baseUrl}" + ) + return false + } + if (item.contentUrl.isNullOrBlank()) { + mindboxLogW( + "InApp is invalid. WebView layer is expected to have non-blank contentUrl. " + + "Actual contentUrl = ${item.contentUrl}" + ) + return false + } + return true + } +} diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppEventManagerImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppEventManagerImpl.kt index ab7225862..b407f808b 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppEventManagerImpl.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppEventManagerImpl.kt @@ -14,7 +14,8 @@ internal class InAppEventManagerImpl : InAppEventManager { val isNotInAppEvent = (listOf( MindboxEventManager.IN_APP_OPERATION_VIEW_TYPE, MindboxEventManager.IN_APP_OPERATION_TARGETING_TYPE, - MindboxEventManager.IN_APP_OPERATION_CLICK_TYPE + MindboxEventManager.IN_APP_OPERATION_CLICK_TYPE, + MindboxEventManager.IN_APP_OPERATION_SHOW_FAILURE_TYPE ).contains(event.name).not()) return isAppStartUp || (isOrdinalEvent && diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppInteractorImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppInteractorImpl.kt index 6c9813a91..758e8613b 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppInteractorImpl.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppInteractorImpl.kt @@ -2,6 +2,7 @@ package cloud.mindbox.mobile_sdk.inapp.domain import cloud.mindbox.mobile_sdk.InitializeLock import cloud.mindbox.mobile_sdk.abtests.InAppABTestLogic +import cloud.mindbox.mobile_sdk.inapp.data.managers.SessionStorageManager import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.checkers.Checker import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.interactors.InAppInteractor import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.InAppEventManager @@ -12,15 +13,16 @@ import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.repositories.InAppReposi import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.repositories.MobileConfigRepository import cloud.mindbox.mobile_sdk.inapp.domain.models.InApp import cloud.mindbox.mobile_sdk.logger.MindboxLog +import cloud.mindbox.mobile_sdk.models.Milliseconds import cloud.mindbox.mobile_sdk.logger.mindboxLogD import cloud.mindbox.mobile_sdk.logger.mindboxLogI import cloud.mindbox.mobile_sdk.models.InAppEventType import cloud.mindbox.mobile_sdk.models.toTimestamp import cloud.mindbox.mobile_sdk.sortByPriority import cloud.mindbox.mobile_sdk.utils.TimeProvider +import cloud.mindbox.mobile_sdk.utils.allAllow import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.* -import cloud.mindbox.mobile_sdk.utils.allAllow internal class InAppInteractorImpl( private val mobileConfigRepository: MobileConfigRepository, @@ -33,12 +35,13 @@ internal class InAppInteractorImpl( private val maxInappsPerSessionLimitChecker: Checker, private val maxInappsPerDayLimitChecker: Checker, private val minIntervalBetweenShowsLimitChecker: Checker, - private val timeProvider: TimeProvider + private val timeProvider: TimeProvider, + private val sessionStorageManager: SessionStorageManager ) : InAppInteractor, MindboxLog { private val inAppTargetingChannel = Channel(Channel.UNLIMITED) - override suspend fun processEventAndConfig(): Flow { + override suspend fun processEventAndConfig(): Flow> { val inApps: List = mobileConfigRepository.getInAppsSection() .let { inApps -> inAppRepository.saveCurrentSessionInApps(inApps) @@ -61,15 +64,16 @@ internal class InAppInteractorImpl( } return inAppRepository.listenInAppEvents() .filter { event -> inAppEventManager.isValidInAppEvent(event) } - .onEach { - mindboxLogD("Event triggered: ${it.name}") + .onEach { event -> + mindboxLogD("Event triggered: ${event.name}") }.map { event -> + val triggerTimeMillis = timeProvider.currentTimestamp() val filteredInApps = inAppFilteringManager.filterUnShownInAppsByEvent(inApps, event).let { inAppFrequencyManager.filterInAppsFrequency(it) } mindboxLogI("Event: ${event.name} combined with $filteredInApps") val prioritySortedInApps = filteredInApps.sortByPriority() - inAppProcessingManager.chooseInAppToShow( + val inApp: InApp? = inAppProcessingManager.chooseInAppToShow( prioritySortedInApps, event ).also { @@ -78,9 +82,13 @@ internal class InAppInteractorImpl( InitializeLock.complete(InitializeLock.State.APP_STARTED) } } + inApp?.let { + sessionStorageManager.inAppTriggerEvent = event + } + inApp?.let { inapp -> inapp to timeProvider.elapsedSince(triggerTimeMillis) } } - .onEach { inApp -> - inApp?.let { mindboxLogI("InApp ${inApp.id} isPriority=${inApp.isPriority}, delayTime=${inApp.delayTime}, skipLimitChecks=${inApp.isPriority}") } + .onEach { pair -> + pair?.let { (inApp, preparedTime) -> mindboxLogI("InApp ${inApp.id} isPriority=${inApp.isPriority}, delayTime=${inApp.delayTime}, skipLimitChecks=${inApp.isPriority}, preparedTime = ${preparedTime.interval} ms") } ?: mindboxLogI("No inapps to show found") } .filterNotNull() @@ -98,9 +106,14 @@ internal class InAppInteractorImpl( ) } - override fun saveShownInApp(id: String, timeStamp: Long) { + override fun saveShownInApp( + id: String, + timeStamp: Long, + timeToDisplay: String, + tags: Map? + ) { inAppRepository.setInAppShown(id) - inAppRepository.sendInAppShown(id) + inAppRepository.sendInAppShown(id, timeToDisplay, tags) inAppRepository.saveShownInApp(id, timeStamp) inAppRepository.saveInAppStateChangeTime(timeStamp.toTimestamp()) } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppProcessingManagerImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppProcessingManagerImpl.kt index ef21c7c72..421e25a2b 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppProcessingManagerImpl.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppProcessingManagerImpl.kt @@ -2,22 +2,32 @@ package cloud.mindbox.mobile_sdk.inapp.domain import cloud.mindbox.mobile_sdk.Mindbox.logI import cloud.mindbox.mobile_sdk.getErrorResponseBodyData +import cloud.mindbox.mobile_sdk.getImageUrl +import cloud.mindbox.mobile_sdk.inapp.domain.extensions.asVolleyError +import cloud.mindbox.mobile_sdk.inapp.domain.extensions.getProductFromTargetingData +import cloud.mindbox.mobile_sdk.inapp.domain.extensions.getVolleyErrorDetails +import cloud.mindbox.mobile_sdk.inapp.domain.extensions.shouldTrackImageDownloadError +import cloud.mindbox.mobile_sdk.inapp.domain.extensions.shouldTrackTargetingError import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.InAppContentFetcher +import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.InAppFailureTracker import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.InAppProcessingManager import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.repositories.InAppGeoRepository import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.repositories.InAppRepository import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.repositories.InAppSegmentationRepository +import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.repositories.InAppTargetingErrorRepository import cloud.mindbox.mobile_sdk.inapp.domain.models.* import cloud.mindbox.mobile_sdk.logger.* import cloud.mindbox.mobile_sdk.models.InAppEventType -import com.android.volley.VolleyError +import cloud.mindbox.mobile_sdk.models.operation.request.FailureReason import kotlinx.coroutines.* internal class InAppProcessingManagerImpl( private val inAppGeoRepository: InAppGeoRepository, private val inAppSegmentationRepository: InAppSegmentationRepository, + private val inAppTargetingErrorRepository: InAppTargetingErrorRepository, private val inAppContentFetcher: InAppContentFetcher, - private val inAppRepository: InAppRepository + private val inAppRepository: InAppRepository, + private val inAppFailureTracker: InAppFailureTracker ) : InAppProcessingManager { companion object { @@ -34,6 +44,7 @@ internal class InAppProcessingManagerImpl( var isTargetingErrorOccurred = false var isInAppContentFetched: Boolean? = null var targetingCheck = false + var imageFetchError: Throwable? = null withContext(Dispatchers.IO) { val imageJob = launch(start = CoroutineStart.LAZY) { @@ -52,6 +63,7 @@ internal class InAppProcessingManagerImpl( is InAppContentFetchingError -> { isInAppContentFetched = false + imageFetchError = throwable } } } @@ -65,6 +77,12 @@ internal class InAppProcessingManagerImpl( is GeoError -> { isTargetingErrorOccurred = true inAppGeoRepository.setGeoStatus(GeoFetchStatus.GEO_FETCH_ERROR) + if (throwable.shouldTrackTargetingError()) { + inAppTargetingErrorRepository.saveError( + key = TargetingErrorKey.Geo, + error = throwable + ) + } MindboxLoggerImpl.e(this, "Error fetching geo", throwable) } @@ -73,24 +91,28 @@ internal class InAppProcessingManagerImpl( inAppSegmentationRepository.setCustomerSegmentationStatus( CustomerSegmentationFetchStatus.SEGMENTATION_FETCH_ERROR ) + if (throwable.shouldTrackTargetingError()) { + inAppTargetingErrorRepository.saveError( + key = TargetingErrorKey.CustomerSegmentation, + error = throwable + ) + } handleCustomerSegmentationErrorLog(throwable) } else -> { MindboxLoggerImpl.e(this, throwable.message ?: "", throwable) + inAppFailureTracker.sendFailure( + inAppId = inApp.id, + failureReason = FailureReason.UNKNOWN_ERROR, + errorDetails = "Unknown exception when checking target ${throwable.message}. ${throwable.cause?.getVolleyErrorDetails() ?: "volleyError=null"}" + ) throw throwable } } } } - listOf(imageJob.apply { - invokeOnCompletion { - if (targetingJob.isActive && isInAppContentFetched == false) { - targetingJob.cancel() - mindboxLogD("Cancelling targeting checking since content loading is $isInAppContentFetched") - } - } - }, targetingJob.apply { + listOf(imageJob, targetingJob.apply { invokeOnCompletion { if (imageJob.isActive && !targetingCheck) { imageJob.cancel() @@ -103,6 +125,16 @@ internal class InAppProcessingManagerImpl( } mindboxLogD("loading and targeting fetching finished") if (isTargetingErrorOccurred) return chooseInAppToShow(inApps, triggerEvent) + trackTargetingErrorIfAny(inApp, data) + if (isInAppContentFetched == false && targetingCheck) { + imageFetchError?.takeIf { it.shouldTrackImageDownloadError() }?.let { error -> + inAppFailureTracker.collectFailure( + inAppId = inApp.id, + failureReason = FailureReason.IMAGE_DOWNLOAD_FAILED, + errorDetails = error.message + "\n Url is ${inApp.form.variants.first().getImageUrl()}" + ) + } + } if (isInAppContentFetched == false) { mindboxLogD("Skipping inApp with id = ${inApp.id} due to content fetching error.") continue @@ -117,9 +149,11 @@ internal class InAppProcessingManagerImpl( inAppId = inApp.id, triggerEvent.hashCode() ) + inAppFailureTracker.clearFailures() return inApp } } + inAppFailureTracker.sendCollectedFailures() return null } @@ -164,7 +198,7 @@ internal class InAppProcessingManagerImpl( } private fun handleCustomerSegmentationErrorLog(error: CustomerSegmentationError) { - val volleyError = error.cause as? VolleyError + val volleyError = error.cause.asVolleyError() volleyError?.let { if ((volleyError.networkResponse?.statusCode == 400) && (volleyError.getErrorResponseBodyData() .contains(RESPONSE_STATUS_CUSTOMER_SEGMENTS_REQUIRE_CUSTOMER)) @@ -176,6 +210,50 @@ internal class InAppProcessingManagerImpl( mindboxLogW("Error fetching customer segmentations", error) } + private fun trackTargetingErrorIfAny(inApp: InApp, data: TargetingData) { + when { + inApp.targeting.hasSegmentationNode() && + inAppSegmentationRepository.getCustomerSegmentationFetched() == CustomerSegmentationFetchStatus.SEGMENTATION_FETCH_ERROR -> { + inAppTargetingErrorRepository.getError(TargetingErrorKey.CustomerSegmentation) + ?.let { errorDetails -> + inAppFailureTracker.collectFailure( + inAppId = inApp.id, + failureReason = FailureReason.CUSTOMER_SEGMENT_REQUEST_FAILED, + errorDetails = errorDetails + ) + } + return + } + + inApp.targeting.hasGeoNode() && + inAppGeoRepository.getGeoFetchedStatus() == GeoFetchStatus.GEO_FETCH_ERROR -> { + inAppTargetingErrorRepository.getError(TargetingErrorKey.Geo) + ?.let { errorDetails -> + inAppFailureTracker.collectFailure( + inAppId = inApp.id, + failureReason = FailureReason.GEO_TARGETING_FAILED, + errorDetails = errorDetails + ) + } + return + } + + inApp.targeting.hasProductSegmentationNode() -> { + data.getProductFromTargetingData()?.let { product -> + inAppTargetingErrorRepository.getError( + TargetingErrorKey.ProductSegmentation(product) + )?.let { errorDetails -> + inAppFailureTracker.collectFailure( + inAppId = inApp.id, + failureReason = FailureReason.PRODUCT_SEGMENT_REQUEST_FAILED, + errorDetails = errorDetails + ) + } + } + } + } + } + private class TargetingDataWrapper( override val triggerEventName: String, override val operationBody: String? = null, diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/extensions/TrackingFailureExtension.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/extensions/TrackingFailureExtension.kt new file mode 100644 index 000000000..663c70bea --- /dev/null +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/extensions/TrackingFailureExtension.kt @@ -0,0 +1,131 @@ +package cloud.mindbox.mobile_sdk.inapp.domain.extensions + +import cloud.mindbox.mobile_sdk.getErrorResponseBodyData +import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.InAppFailureTracker +import cloud.mindbox.mobile_sdk.inapp.domain.models.TargetingData +import cloud.mindbox.mobile_sdk.models.operation.request.OperationBodyRequest +import cloud.mindbox.mobile_sdk.utils.loggingRunCatching +import com.android.volley.NoConnectionError +import com.android.volley.TimeoutError +import com.android.volley.VolleyError +import com.bumptech.glide.load.HttpException +import com.bumptech.glide.load.engine.GlideException +import com.google.gson.Gson +import java.net.ConnectException +import java.net.SocketTimeoutException +import java.net.UnknownHostException +import cloud.mindbox.mobile_sdk.logger.mindboxLogE +import cloud.mindbox.mobile_sdk.models.operation.request.FailureReason + +internal fun VolleyError.isTimeoutError(): Boolean { + return this is TimeoutError || cause is SocketTimeoutException +} + +internal fun VolleyError.isNoConnectionError(): Boolean { + return this is NoConnectionError +} + +internal fun VolleyError.isServerError(): Boolean { + val statusCode = networkResponse?.statusCode ?: return false + return statusCode in 500..599 +} + +internal fun Throwable.shouldTrackTargetingError(): Boolean { + val volleyError = cause.asVolleyError() ?: return false + return volleyError.isServerError() && !volleyError.isTimeoutError() && !volleyError.isNoConnectionError() +} + +internal fun Throwable.shouldTrackImageDownloadError(): Boolean { + val glideException = cause as? GlideException ?: return true + return glideException.rootCauses.none { rootCause -> + when { + rootCause is SocketTimeoutException || rootCause.cause is SocketTimeoutException -> true + rootCause is HttpException && rootCause.statusCode <= 0 -> + rootCause.cause is UnknownHostException || rootCause.cause is ConnectException + else -> false + } + } +} + +internal fun Throwable?.asVolleyError(): VolleyError? = this as? VolleyError + +internal fun Throwable.getVolleyErrorDetails(): String { + val volleyError = this.asVolleyError() ?: return "volleyError = null" + val statusCode = volleyError.networkResponse?.statusCode ?: "timeout error" + val networkTimeMs = volleyError.networkTimeMs + val body = volleyError.getErrorResponseBodyData() + return "statusCode=$statusCode, networkTimeMs=$networkTimeMs, body=$body" +} + +internal fun TargetingData.getProductFromTargetingData(): Pair? { + if (this !is TargetingData.OperationBody) return null + return parseOperationBody(this.operationBody) +} + +private fun parseOperationBody(operationBody: String?): Pair? = + loggingRunCatching(null) { + val body = Gson().fromJson(operationBody, OperationBodyRequest::class.java) ?: return@loggingRunCatching null + body.viewProductRequest + ?.product + ?.ids + ?.ids + ?.entries + ?.firstOrNull() + ?.takeIf { entry -> + entry.value?.isNotBlank() == true + } + ?.let { entry -> entry.key to entry.value!! } + } + +internal fun InAppFailureTracker.sendPresentationFailure( + inAppId: String, + errorDescription: String, + throwable: Throwable? = null +) { + val errorDetails = when { + throwable != null -> "$errorDescription: ${throwable.message ?: "Unknown error"}" + else -> errorDescription + } + mindboxLogE(errorDetails) + sendFailure( + inAppId = inAppId, + failureReason = FailureReason.PRESENTATION_FAILED, + errorDetails = errorDetails + ) +} + +internal fun InAppFailureTracker.sendFailureWithContext( + inAppId: String, + failureReason: FailureReason, + errorDescription: String, + throwable: Throwable? = null +) { + val errorDetails = when { + throwable != null -> "$errorDescription: ${throwable.message ?: "Unknown error"}" + else -> errorDescription + } + mindboxLogE(errorDetails) + sendFailure( + inAppId = inAppId, + failureReason = failureReason, + errorDetails = errorDetails + ) +} + +internal inline fun InAppFailureTracker.executeWithFailureTracking( + inAppId: String, + failureReason: FailureReason, + errorDescription: String, + crossinline onFailure: () -> Unit = {}, + block: () -> T +): Result { + return runCatching(block).onFailure { throwable -> + sendFailureWithContext( + inAppId = inAppId, + failureReason = failureReason, + errorDescription = errorDescription, + throwable = throwable + ) + onFailure() + } +} diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/PermissionManager.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/PermissionManager.kt index 1f9a78aed..4b81ffe16 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/PermissionManager.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/PermissionManager.kt @@ -1,6 +1,26 @@ package cloud.mindbox.mobile_sdk.inapp.domain.interfaces +internal enum class PermissionStatus(val value: String) { + GRANTED("granted"), + DENIED("denied"), + NOT_DETERMINED("notDetermined"), + RESTRICTED("restricted"), + LIMITED("limited"), +} + internal interface PermissionManager { - fun isNotificationEnabled(): Boolean + fun getCameraPermissionStatus(): PermissionStatus + + fun getLocationPermissionStatus(): PermissionStatus + + fun getMicrophonePermissionStatus(): PermissionStatus + + fun getNotificationPermissionStatus(): PermissionStatus + + fun getPhotoLibraryPermissionStatus(): PermissionStatus + + fun isNotificationEnabled(): Boolean { + return getNotificationPermissionStatus() == PermissionStatus.GRANTED + } } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/interactors/InAppInteractor.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/interactors/InAppInteractor.kt index cf558beb4..44d940eb6 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/interactors/InAppInteractor.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/interactors/InAppInteractor.kt @@ -1,6 +1,7 @@ package cloud.mindbox.mobile_sdk.inapp.domain.interfaces.interactors import cloud.mindbox.mobile_sdk.inapp.domain.models.InApp +import cloud.mindbox.mobile_sdk.models.Milliseconds import kotlinx.coroutines.flow.Flow internal interface InAppInteractor { @@ -9,9 +10,14 @@ internal interface InAppInteractor { fun setInAppShown(inAppId: String) - suspend fun processEventAndConfig(): Flow + suspend fun processEventAndConfig(): Flow> - fun saveShownInApp(id: String, timeStamp: Long) + fun saveShownInApp( + id: String, + timeStamp: Long, + timeToDisplay: String, + tags: Map? + ) fun sendInAppClicked(inAppId: String) diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/managers/FeatureToggleManager.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/managers/FeatureToggleManager.kt new file mode 100644 index 000000000..244e6a8ad --- /dev/null +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/managers/FeatureToggleManager.kt @@ -0,0 +1,10 @@ +package cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers + +import cloud.mindbox.mobile_sdk.models.operation.response.InAppConfigResponse + +internal interface FeatureToggleManager { + + fun applyToggles(config: InAppConfigResponse?) + + fun isEnabled(key: String): Boolean +} diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/managers/InAppFailureTracker.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/managers/InAppFailureTracker.kt new file mode 100644 index 000000000..9c4f4e268 --- /dev/null +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/managers/InAppFailureTracker.kt @@ -0,0 +1,22 @@ +package cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers + +import cloud.mindbox.mobile_sdk.models.operation.request.FailureReason + +internal interface InAppFailureTracker { + + fun sendFailure( + inAppId: String, + failureReason: FailureReason, + errorDetails: String? + ) + + fun collectFailure( + inAppId: String, + failureReason: FailureReason, + errorDetails: String? + ) + + fun sendCollectedFailures() + + fun clearFailures() +} diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/managers/InAppSerializationManager.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/managers/InAppSerializationManager.kt index 9f5f55a6f..e01acce06 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/managers/InAppSerializationManager.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/managers/InAppSerializationManager.kt @@ -1,12 +1,18 @@ package cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers +import cloud.mindbox.mobile_sdk.models.operation.request.InAppShowFailure + internal interface InAppSerializationManager { fun serializeToShownInAppsString(shownInApps: Map>): String fun deserializeToShownInAppsMap(shownInApps: String): Map> - fun serializeToInAppHandledString(inAppId: String): String + fun serializeToInAppShownActionString(inAppId: String, timeToDisplay: String, tags: Map?): String + + fun serializeToInAppActionString(inAppId: String): String + + fun serializeToInAppShowFailuresString(inAppShowFailures: List): String fun deserializeToShownInApps(shownInApps: String): Set } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/repositories/InAppRepository.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/repositories/InAppRepository.kt index d729fd476..eba668ef2 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/repositories/InAppRepository.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/repositories/InAppRepository.kt @@ -3,6 +3,7 @@ package cloud.mindbox.mobile_sdk.inapp.domain.interfaces.repositories import cloud.mindbox.mobile_sdk.inapp.domain.models.InApp import cloud.mindbox.mobile_sdk.models.InAppEventType import cloud.mindbox.mobile_sdk.models.Timestamp +import cloud.mindbox.mobile_sdk.models.operation.request.InAppShowFailure import kotlinx.coroutines.flow.Flow internal interface InAppRepository { @@ -28,12 +29,14 @@ internal interface InAppRepository { fun saveShownInApp(id: String, timeStamp: Long) - fun sendInAppShown(inAppId: String) + fun sendInAppShown(inAppId: String, timeToDisplay: String, tags: Map?) fun sendInAppClicked(inAppId: String) fun sendUserTargeted(inAppId: String) + fun sendInAppShowFailure(failures: List) + fun setInAppShown(inAppId: String) fun isInAppShown(inAppId: String): Boolean diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/repositories/InAppTargetingErrorRepository.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/repositories/InAppTargetingErrorRepository.kt new file mode 100644 index 000000000..8c482d6d6 --- /dev/null +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/repositories/InAppTargetingErrorRepository.kt @@ -0,0 +1,11 @@ +package cloud.mindbox.mobile_sdk.inapp.domain.interfaces.repositories + +import cloud.mindbox.mobile_sdk.inapp.domain.models.TargetingErrorKey + +internal interface InAppTargetingErrorRepository { + fun saveError(key: TargetingErrorKey, error: Throwable) + + fun getError(key: TargetingErrorKey): String? + + fun clearErrors() +} diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/models/InAppConfig.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/models/InAppConfig.kt index 5283addda..e5a3f75b6 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/models/InAppConfig.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/models/InAppConfig.kt @@ -28,6 +28,7 @@ internal data class InApp( val frequency: Frequency, val targeting: TreeTargeting, val form: Form, + val tags: Map?, ) internal data class Frequency(val delay: Delay) { diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/models/InAppFailuresWrapper.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/models/InAppFailuresWrapper.kt new file mode 100644 index 000000000..2596787a6 --- /dev/null +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/models/InAppFailuresWrapper.kt @@ -0,0 +1,7 @@ +package cloud.mindbox.mobile_sdk.inapp.domain.models + +import cloud.mindbox.mobile_sdk.models.operation.request.InAppShowFailure + +internal data class InAppFailuresWrapper( + val failures: List +) diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/models/InAppTypeWrapper.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/models/InAppTypeWrapper.kt index 0fab35bf6..f520c7ab9 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/models/InAppTypeWrapper.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/models/InAppTypeWrapper.kt @@ -4,7 +4,8 @@ import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.InAppActionCallbacks internal data class InAppTypeWrapper( val inAppType: T, - val inAppActionCallbacks: InAppActionCallbacks + val inAppActionCallbacks: InAppActionCallbacks, + val onRenderStart: () -> Unit, ) internal fun interface OnInAppClick { diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/models/TargetingErrorKey.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/models/TargetingErrorKey.kt new file mode 100644 index 000000000..ced3aac3b --- /dev/null +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/models/TargetingErrorKey.kt @@ -0,0 +1,11 @@ +package cloud.mindbox.mobile_sdk.inapp.domain.models + +internal sealed interface TargetingErrorKey { + data object CustomerSegmentation : TargetingErrorKey + + data object Geo : TargetingErrorKey + + data class ProductSegmentation( + val product: Pair, + ) : TargetingErrorKey +} diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/models/TreeTargeting.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/models/TreeTargeting.kt index 601fcc233..c9d4e978c 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/models/TreeTargeting.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/models/TreeTargeting.kt @@ -28,6 +28,8 @@ internal interface TargetingInfo { fun hasOperationNode(): Boolean + fun hasProductSegmentationNode(): Boolean = false + suspend fun getOperationsSet(): Set } @@ -238,6 +240,8 @@ internal sealed class TreeTargeting(open val type: String) : } return false } + + override fun hasProductSegmentationNode() = nodes.any { it.hasProductSegmentationNode() } } internal data class UnionNode( @@ -288,6 +292,8 @@ internal sealed class TreeTargeting(open val type: String) : } return false } + + override fun hasProductSegmentationNode() = nodes.any { it.hasProductSegmentationNode() } } internal data class SegmentNode( diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/models/ViewProductSegmentNode.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/models/ViewProductSegmentNode.kt index 4d32f0007..0cbaa6808 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/models/ViewProductSegmentNode.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/models/ViewProductSegmentNode.kt @@ -1,6 +1,7 @@ package cloud.mindbox.mobile_sdk.inapp.domain.models import cloud.mindbox.mobile_sdk.di.mindboxInject +import cloud.mindbox.mobile_sdk.inapp.domain.extensions.shouldTrackTargetingError import cloud.mindbox.mobile_sdk.logger.mindboxLogE import cloud.mindbox.mobile_sdk.models.operation.request.OperationBodyRequest @@ -13,6 +14,7 @@ internal data class ViewProductSegmentNode( private val mobileConfigRepository by mindboxInject { mobileConfigRepository } private val inAppSegmentationRepository by mindboxInject { inAppSegmentationRepository } + private val inAppTargetingErrorRepository by mindboxInject { inAppTargetingErrorRepository } private val gson by mindboxInject { gson } private val sessionStorageManager by mindboxInject { sessionStorageManager } @@ -31,6 +33,12 @@ internal data class ViewProductSegmentNode( if (error is ProductSegmentationError) { sessionStorageManager.processedProductSegmentations[product] = ProductSegmentationFetchStatus.SEGMENTATION_FETCH_ERROR + if (error.shouldTrackTargetingError()) { + inAppTargetingErrorRepository.saveError( + key = TargetingErrorKey.ProductSegmentation(product), + error = error + ) + } mindboxLogE("Error fetching product segmentations for product $product") } } @@ -62,4 +70,6 @@ internal data class ViewProductSegmentNode( setOf(it) } ?: setOf() } + + override fun hasProductSegmentationNode(): Boolean = true } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageDelayedManager.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageDelayedManager.kt index 266e242fd..36f375e5c 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageDelayedManager.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageDelayedManager.kt @@ -3,6 +3,7 @@ package cloud.mindbox.mobile_sdk.inapp.presentation import cloud.mindbox.mobile_sdk.Mindbox import cloud.mindbox.mobile_sdk.inapp.domain.models.InApp import cloud.mindbox.mobile_sdk.logger.mindboxLogD +import cloud.mindbox.mobile_sdk.models.Milliseconds import cloud.mindbox.mobile_sdk.logger.mindboxLogI import cloud.mindbox.mobile_sdk.pollIf import cloud.mindbox.mobile_sdk.utils.TimeProvider @@ -33,16 +34,17 @@ internal class InAppMessageDelayedManager(private val timeProvider: TimeProvider pendingInAppComparator ) - private val _inAppToShowFlow = MutableSharedFlow() + private val _inAppToShowFlow = MutableSharedFlow>() val inAppToShowFlow = _inAppToShowFlow.asSharedFlow() private data class PendingInApp( val inApp: InApp, val showTimeMillis: Long, - val sequenceNumber: Long + val sequenceNumber: Long, + val preparedTimeMs: Milliseconds, ) - internal fun process(inApp: InApp) { + internal fun process(inApp: InApp, preparedTimeMs: Milliseconds) { coroutineScope.launchWithLock(processingMutex) { mindboxLogD("Processing In-App: ${inApp.id}, Priority: ${inApp.isPriority}, Delay: ${inApp.delayTime}") val delay = inApp.delayTime?.interval ?: 0L @@ -52,7 +54,8 @@ internal class InAppMessageDelayedManager(private val timeProvider: TimeProvider PendingInApp( inApp = inApp, showTimeMillis = showTime, - sequenceNumber = sequenceNumber.getAndIncrement() + sequenceNumber = sequenceNumber.getAndIncrement(), + preparedTimeMs = preparedTimeMs, ) ) processQueue() @@ -73,7 +76,7 @@ internal class InAppMessageDelayedManager(private val timeProvider: TimeProvider pendingInApps.pollIf { it.showTimeMillis <= now }?.let { showCandidate -> mindboxLogI("Winner found: ${showCandidate.inApp.id}. Emitting to show.") - _inAppToShowFlow.emit(showCandidate.inApp) + _inAppToShowFlow.emit(showCandidate.inApp to showCandidate.preparedTimeMs) do { val inApp = pendingInApps.pollIf { it.showTimeMillis <= now }.also { discarded -> 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 18c418e1e..15edca038 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 @@ -4,8 +4,9 @@ import android.app.Activity import cloud.mindbox.mobile_sdk.InitializeLock import cloud.mindbox.mobile_sdk.Mindbox import cloud.mindbox.mobile_sdk.inapp.data.managers.SessionStorageManager -import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.interactors.InAppInteractor import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.InAppActionCallbacks +import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.interactors.InAppInteractor +import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppType import cloud.mindbox.mobile_sdk.inapp.domain.models.OnInAppClick import cloud.mindbox.mobile_sdk.inapp.domain.models.OnInAppDismiss import cloud.mindbox.mobile_sdk.inapp.domain.models.OnInAppShown @@ -13,9 +14,13 @@ import cloud.mindbox.mobile_sdk.logger.MindboxLoggerImpl import cloud.mindbox.mobile_sdk.logger.mindboxLogI import cloud.mindbox.mobile_sdk.managers.MindboxEventManager import cloud.mindbox.mobile_sdk.managers.UserVisitManager +import cloud.mindbox.mobile_sdk.millisToTimeSpan +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 com.android.volley.VolleyError import kotlinx.coroutines.* import kotlinx.coroutines.flow.collect @@ -28,7 +33,8 @@ internal class InAppMessageManagerImpl( private val monitoringInteractor: MonitoringInteractor, private val sessionStorageManager: SessionStorageManager, private val userVisitManager: UserVisitManager, - private val inAppMessageDelayedManager: InAppMessageDelayedManager + private val inAppMessageDelayedManager: InAppMessageDelayedManager, + private val timeProvider: TimeProvider ) : InAppMessageManager { init { @@ -65,15 +71,15 @@ internal class InAppMessageManagerImpl( private suspend fun handleInAppFromInteractor() { inAppInteractor.processEventAndConfig() - .onEach { inApp -> + .onEach { (inApp, preparedTimeMs) -> mindboxLogI("Got in-app from interactor: ${inApp.id}. Processing with DelayedManager.") - inAppMessageDelayedManager.process(inApp) + inAppMessageDelayedManager.process(inApp, preparedTimeMs) } .collect() } private suspend fun handleInAppFromDelayedManager() { - inAppMessageDelayedManager.inAppToShowFlow.collect { inApp -> + inAppMessageDelayedManager.inAppToShowFlow.collect { (inApp, preparedTimeMs) -> mindboxLogI("Got in-app from DelayedManager: ${inApp.id}") withContext(Dispatchers.Main) { if (inAppMessageViewDisplayer.isInAppActive()) { @@ -92,14 +98,18 @@ internal class InAppMessageManagerImpl( return@withContext } + var renderStartTime = Timestamp(0L) + val tags = inApp.tags?.takeIf { it.isNotEmpty() } + inAppMessageViewDisplayer.tryShowInAppMessage( inAppType = inAppMessage, + onRenderStart = { renderStartTime = timeProvider.currentTimestamp() }, inAppActionCallbacks = object : InAppActionCallbacks { override val onInAppClick = OnInAppClick { inAppInteractor.sendInAppClicked(inAppMessage.inAppId) } override val onInAppShown = OnInAppShown { - inAppInteractor.saveShownInApp(inAppMessage.inAppId, System.currentTimeMillis()) + handleInAppShown(renderStartTime, preparedTimeMs, inAppMessage, tags) } override val onInAppDismiss = OnInAppDismiss { inAppInteractor.saveInAppDismissTime() @@ -179,7 +189,7 @@ internal class InAppMessageManagerImpl( override fun handleSessionExpiration() { inAppScope.launch { withContext(Dispatchers.Main) { - inAppMessageViewDisplayer.hideCurrentInApp() + inAppMessageViewDisplayer.dismissCurrentInApp() } processingJob?.cancel() inAppInteractor.resetInAppConfigAndEvents() @@ -194,6 +204,19 @@ internal class InAppMessageManagerImpl( } } + private fun handleInAppShown( + renderStartTime: Timestamp, + preparedTimeMs: Milliseconds, + inAppMessage: InAppType, + tags: Map? + ) { + val shownTime = timeProvider.currentTimestamp() + val renderTime = shownTime - renderStartTime + mindboxLogI("Render time is ${renderTime.ms}ms, prepared time is ${preparedTimeMs.interval}ms") + val timeToDisplay = (preparedTimeMs.interval + renderTime.ms).millisToTimeSpan() + inAppInteractor.saveShownInApp(inAppMessage.inAppId, shownTime.ms, timeToDisplay, tags) + } + companion object { const val CONFIG_NOT_FOUND = 404 } 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 73f99d201..db2a4fdbb 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 @@ -14,7 +14,8 @@ internal interface InAppMessageViewDisplayer { fun tryShowInAppMessage( inAppType: InAppType, - inAppActionCallbacks: InAppActionCallbacks + inAppActionCallbacks: InAppActionCallbacks, + onRenderStart: () -> Unit = {}, ) fun registerCurrentActivity(activity: Activity) @@ -23,5 +24,5 @@ internal interface InAppMessageViewDisplayer { fun isInAppActive(): Boolean - fun hideCurrentInApp() + 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 f640a1fe9..29bcc769a 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 @@ -2,25 +2,25 @@ package cloud.mindbox.mobile_sdk.inapp.presentation import android.app.Activity import android.view.ViewGroup -import androidx.annotation.VisibleForTesting import cloud.mindbox.mobile_sdk.addUnique import cloud.mindbox.mobile_sdk.di.mindboxInject -import cloud.mindbox.mobile_sdk.fromJson -import cloud.mindbox.mobile_sdk.inapp.data.dto.BackgroundDto -import cloud.mindbox.mobile_sdk.inapp.data.dto.PayloadDto +import cloud.mindbox.mobile_sdk.inapp.domain.extensions.executeWithFailureTracking +import cloud.mindbox.mobile_sdk.inapp.domain.extensions.sendPresentationFailure import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.InAppActionCallbacks import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.InAppImageSizeStorage +import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.InAppFailureTracker import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppType import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppTypeWrapper -import cloud.mindbox.mobile_sdk.inapp.domain.models.Layer import cloud.mindbox.mobile_sdk.inapp.presentation.callbacks.* +import cloud.mindbox.mobile_sdk.inapp.presentation.view.ActivityBackPressRegistrar +import cloud.mindbox.mobile_sdk.inapp.presentation.view.BackPressRegistrar import cloud.mindbox.mobile_sdk.inapp.presentation.view.InAppViewHolder import cloud.mindbox.mobile_sdk.inapp.presentation.view.ModalWindowInAppViewHolder import cloud.mindbox.mobile_sdk.inapp.presentation.view.SnackbarInAppViewHolder import cloud.mindbox.mobile_sdk.inapp.presentation.view.WebViewInAppViewHolder -import cloud.mindbox.mobile_sdk.logger.mindboxLogE import cloud.mindbox.mobile_sdk.logger.mindboxLogI import cloud.mindbox.mobile_sdk.logger.mindboxLogW +import cloud.mindbox.mobile_sdk.models.operation.request.FailureReason import cloud.mindbox.mobile_sdk.postDelayedAnimation import cloud.mindbox.mobile_sdk.root import cloud.mindbox.mobile_sdk.utils.MindboxUtils.Stopwatch @@ -31,10 +31,14 @@ internal interface MindboxView { val container: ViewGroup + val backPressRegistrar: BackPressRegistrar + fun requestPermission() } -internal class InAppMessageViewDisplayerImpl(private val inAppImageSizeStorage: InAppImageSizeStorage) : +internal class InAppMessageViewDisplayerImpl( + private val inAppImageSizeStorage: InAppImageSizeStorage +) : InAppMessageViewDisplayer { companion object { @@ -53,7 +57,7 @@ internal class InAppMessageViewDisplayerImpl(private val inAppImageSizeStorage: private var currentHolder: InAppViewHolder<*>? = null private var pausedHolder: InAppViewHolder<*>? = null private val mindboxNotificationManager by mindboxInject { mindboxNotificationManager } - private val gson by mindboxInject { gson } + private val inAppFailureTracker: InAppFailureTracker by mindboxInject { inAppFailureTracker } private fun isUiPresent(): Boolean = currentActivity?.isFinishing?.not() ?: false @@ -70,10 +74,9 @@ internal class InAppMessageViewDisplayerImpl(private val inAppImageSizeStorage: inAppActionCallbacks = wrapper.inAppActionCallbacks.copy(onInAppShown = { mindboxLogI("Skip InApp.Show for restored inApp") currentActivity?.postDelayedAnimation { - pausedHolder?.hide() + pausedHolder?.onClose() } - } - ) + }) ), isRestored = true ) @@ -109,7 +112,7 @@ internal class InAppMessageViewDisplayerImpl(private val inAppImageSizeStorage: override fun onStopCurrentActivity(activity: Activity) { mindboxLogI("onStopCurrentActivity: ${activity.hashCode()}") - pausedHolder?.hide() + pausedHolder?.onStop() } override fun onPauseCurrentActivity(activity: Activity) { @@ -117,54 +120,18 @@ internal class InAppMessageViewDisplayerImpl(private val inAppImageSizeStorage: if (currentActivity == activity) { currentActivity = null } - pausedHolder = currentHolder + val holderToPause = currentHolder ?: return + pausedHolder?.onClose() + pausedHolder = holderToPause currentHolder = null } - @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) - internal fun getWebViewFromPayload(inAppType: InAppType, inAppId: String): InAppType.WebView? { - val layer = when (inAppType) { - is InAppType.Snackbar -> inAppType.layers.firstOrNull() - is InAppType.ModalWindow -> inAppType.layers.firstOrNull() - is InAppType.WebView -> return inAppType - } - if (layer !is Layer.ImageLayer) { - return null - } - - val payload = when (layer.action) { - is Layer.ImageLayer.Action.RedirectUrlAction -> layer.action.payload - is Layer.ImageLayer.Action.PushPermissionAction -> layer.action.payload - } - runCatching { - val layerDto = gson.fromJson(payload).getOrThrow() - requireNotNull(layerDto.type) - requireNotNull(layerDto.contentUrl) - requireNotNull(layerDto.baseUrl) - Layer.WebViewLayer( - baseUrl = layerDto.baseUrl, - contentUrl = layerDto.contentUrl, - type = layerDto.type, - params = layerDto.params ?: emptyMap() - ) - }.getOrNull()?.let { webView -> - return InAppType.WebView( - inAppId = inAppId, - type = PayloadDto.WebViewDto.WEBVIEW_JSON_NAME, - layers = listOf(webView), - ) - } - - return null - } - override fun tryShowInAppMessage( inAppType: InAppType, - inAppActionCallbacks: InAppActionCallbacks + inAppActionCallbacks: InAppActionCallbacks, + onRenderStart: () -> Unit, ) { - val wrapper = getWebViewFromPayload(inAppType, inAppType.inAppId)?.let { - InAppTypeWrapper(it, inAppActionCallbacks) - } ?: InAppTypeWrapper(inAppType, inAppActionCallbacks) + val wrapper = InAppTypeWrapper(inAppType, inAppActionCallbacks, onRenderStart) if (isUiPresent() && currentHolder == null && pausedHolder == null) { val duration = Stopwatch.track(Stopwatch.INIT_SDK) @@ -191,28 +158,38 @@ internal class InAppMessageViewDisplayerImpl(private val inAppImageSizeStorage: wrapper: InAppTypeWrapper, isRestored: Boolean = false, ) { - if (!isRestored) isActionExecuted = false + if (!isRestored) { + wrapper.onRenderStart() + isActionExecuted = false + } + if (isRestored && tryReattachRestoredInApp(wrapper.inAppType.inAppId)) return + if (isRestored) { + pausedHolder?.onClose() + pausedHolder = null + } + val callbackWrapper = InAppCallbackWrapper(inAppCallback) { wrapper.inAppActionCallbacks.onInAppDismiss.onDismiss() - pausedHolder?.hide() - pausedHolder = null - currentHolder = null } + val controller = InAppViewHolder.InAppController { closeInApp() } @Suppress("UNCHECKED_CAST") currentHolder = when (wrapper.inAppType) { is InAppType.WebView -> WebViewInAppViewHolder( wrapper = wrapper as InAppTypeWrapper, + controller = controller, inAppCallback = callbackWrapper ) is InAppType.ModalWindow -> ModalWindowInAppViewHolder( wrapper = wrapper as InAppTypeWrapper, + controller = controller, inAppCallback = callbackWrapper ) is InAppType.Snackbar -> SnackbarInAppViewHolder( wrapper = wrapper as InAppTypeWrapper, + controller = controller, inAppCallback = callbackWrapper, inAppImageSizeStorage = inAppImageSizeStorage, isFirstShow = !isRestored @@ -220,22 +197,58 @@ internal class InAppMessageViewDisplayerImpl(private val inAppImageSizeStorage: } currentActivity?.root?.let { root -> - currentHolder?.show(object : MindboxView { - override val container: ViewGroup - get() = root - - override fun requestPermission() { - currentActivity?.let { activity -> - mindboxNotificationManager.requestPermission(activity = activity) - } - } - }) + inAppFailureTracker.executeWithFailureTracking( + inAppId = wrapper.inAppType.inAppId, + failureReason = FailureReason.PRESENTATION_FAILED, + errorDescription = "Error when trying draw inapp", + onFailure = ::closeInApp + ) { + currentHolder?.show(createMindboxView(root)) + } } ?: run { - mindboxLogE("failed to show inApp: currentRoot is null") + inAppFailureTracker.sendPresentationFailure( + inAppId = wrapper.inAppType.inAppId, + errorDescription = "currentRoot is null" + ) + } + } + + private fun tryReattachRestoredInApp(inAppId: String): Boolean { + val restoredHolder: InAppViewHolder<*> = pausedHolder + ?.takeIf { it.canReuseOnRestore(inAppId) } + ?: return false + currentHolder = restoredHolder + pausedHolder = null + val root: ViewGroup = currentActivity?.root ?: run { + inAppFailureTracker.sendPresentationFailure( + inAppId = inAppId, + errorDescription = "failed to reattach inApp: currentRoot is null" + ) + return true } + inAppFailureTracker.executeWithFailureTracking( + inAppId = inAppId, + failureReason = FailureReason.PRESENTATION_FAILED, + errorDescription = "Error when trying reattach InApp", + onFailure = ::closeInApp, + ) { + restoredHolder.reattach(createMindboxView(root)) + } + return true } - override fun hideCurrentInApp() { + private fun createMindboxView(root: ViewGroup): MindboxView = + object : MindboxView { + override val container: ViewGroup = root + override val backPressRegistrar: BackPressRegistrar = + ActivityBackPressRegistrar(activityProvider = { currentActivity }) + + override fun requestPermission() { + currentActivity?.let { mindboxNotificationManager.requestPermission(activity = it) } + } + } + + override fun dismissCurrentInApp() { loggingRunCatching { if (isInAppActive()) { currentHolder?.wrapper?.inAppActionCallbacks @@ -243,15 +256,15 @@ internal class InAppMessageViewDisplayerImpl(private val inAppImageSizeStorage: ?.onInAppDismiss ?.onDismiss() } - currentHolder?.apply { - hide() - release() - } + } + closeInApp() + } + + private fun closeInApp() { + loggingRunCatching { + currentHolder?.onClose() currentHolder = null - pausedHolder?.apply { - hide() - release() - } + pausedHolder?.onClose() pausedHolder = null inAppQueue.clear() isActionExecuted = false diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/MindboxNotificationManager.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/MindboxNotificationManager.kt index ac1992668..6e9002131 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/MindboxNotificationManager.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/MindboxNotificationManager.kt @@ -6,7 +6,9 @@ internal interface MindboxNotificationManager { fun isNotificationEnabled(): Boolean - fun openNotificationSettings(activity: Activity) + fun openNotificationSettings(activity: Activity, channelId: String? = null) + + fun openApplicationSettings(activity: Activity) fun requestPermission(activity: Activity) diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/MindboxNotificationManagerImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/MindboxNotificationManagerImpl.kt index d2d5553fb..e089e139d 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/MindboxNotificationManagerImpl.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/MindboxNotificationManagerImpl.kt @@ -3,6 +3,7 @@ package cloud.mindbox.mobile_sdk.inapp.presentation import android.app.Activity import android.content.Context import android.content.Intent +import android.net.Uri import android.os.Build import android.provider.Settings import androidx.core.app.NotificationManagerCompat @@ -11,7 +12,7 @@ import cloud.mindbox.mobile_sdk.logger.mindboxLogE import cloud.mindbox.mobile_sdk.logger.mindboxLogI import cloud.mindbox.mobile_sdk.managers.RequestPermissionManager import cloud.mindbox.mobile_sdk.utils.Constants -import cloud.mindbox.mobile_sdk.utils.LoggingExceptionHandler +import cloud.mindbox.mobile_sdk.utils.loggingRunCatching internal class MindboxNotificationManagerImpl( private val context: Context, @@ -29,15 +30,15 @@ internal class MindboxNotificationManagerImpl( } } - override fun openNotificationSettings(activity: Activity) { - LoggingExceptionHandler.runCatching { + override fun openNotificationSettings(activity: Activity, channelId: String?) { + loggingRunCatching { val intent = when { Build.VERSION.SDK_INT >= Build.VERSION_CODES.O -> { Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply { putExtra(Settings.EXTRA_APP_PACKAGE, activity.packageName) + putExtra(Settings.EXTRA_CHANNEL_ID, channelId) } } - else -> { Intent(Constants.NOTIFICATION_SETTINGS).apply { putExtra(Constants.APP_PACKAGE_NAME, activity.packageName) @@ -45,16 +46,25 @@ internal class MindboxNotificationManagerImpl( } } } - mindboxLogI("Opening notification settings.") + mindboxLogI("Opening notification settings") + activity.startActivity(intent) + } + } + + override fun openApplicationSettings(activity: Activity) { + loggingRunCatching { + val packageUri: Uri = Uri.fromParts("package", activity.packageName, null) + val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, packageUri) + mindboxLogI("Opening application settings") activity.startActivity(intent) } } override fun requestPermission(activity: Activity) { - LoggingExceptionHandler.runCatching { + loggingRunCatching { if (NotificationManagerCompat.from(context).areNotificationsEnabled()) { mindboxLogI("Notification is enabled now, don't try request permission") - return@runCatching + return@loggingRunCatching } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { 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 7ad5f78f9..d78d6e113 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 @@ -19,10 +19,15 @@ internal class PushActivationActivity : Activity() { private val requestPermissionManager by mindboxInject { requestPermissionManager } private var shouldCheckDialogShowing = false private val resumeTimes = mutableListOf() + private var requestId: String? = null + private var isResultSent: Boolean = false + private var isNeedToRouteSettings: Boolean = true companion object { private const val PERMISSION_REQUEST_CODE = 125129 private const val TIME_BETWEEN_RESUME = 700 + internal const val EXTRA_REQUEST_ID: String = "runtime_permission_request_id" + internal const val EXTRA_ROUTE_TO_SETTINGS: String = "runtime_permission_route_to_settings" } @RequiresApi(Build.VERSION_CODES.M) @@ -43,28 +48,30 @@ internal class PushActivationActivity : Activity() { granted -> { mindboxLogI("User clicked 'allow' in request permission") Mindbox.updateNotificationPermissionStatus(this) - finish() + finishWithResult(isGranted = true) } permissionDenied && !shouldShowRationale -> { if (mindboxNotificationManager.shouldOpenSettings) { if (requestPermissionManager.getRequestCount() > 1) { - mindboxLogI("User already rejected permission two times, try open settings") - mindboxNotificationManager.openNotificationSettings(this) - finish() + if (isNeedToRouteSettings) { + mindboxLogI("User already rejected permission two times, try open settings") + mindboxNotificationManager.openNotificationSettings(this) + } + finishWithResult(isGranted = false, dialogShown = false) } else { mindboxLogI("Awaiting show dialog") shouldCheckDialogShowing = true } } else { mindboxNotificationManager.shouldOpenSettings = true - finish() + finishWithResult(isGranted = false) } } permissionDenied && shouldShowRationale -> { mindboxLogI("User rejected first permission request") - finish() + finishWithResult(isGranted = false) } } } @@ -77,7 +84,9 @@ internal class PushActivationActivity : Activity() { ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT ) - mindboxLogI("Call permission laucher") + requestId = intent?.getStringExtra(EXTRA_REQUEST_ID) + isNeedToRouteSettings = intent?.getBooleanExtra(EXTRA_ROUTE_TO_SETTINGS, true) ?: true + mindboxLogI("Call permission launcher") requestPermissions(arrayOf(Constants.POST_NOTIFICATION), PERMISSION_REQUEST_CODE) } @@ -85,7 +94,8 @@ internal class PushActivationActivity : Activity() { resumeTimes.add(SystemClock.elapsedRealtime()) if (shouldCheckDialogShowing) { val duration = resumeTimes.last() - resumeTimes.first() - if (duration < TIME_BETWEEN_RESUME) { + val dialogShown = duration >= TIME_BETWEEN_RESUME + if (!dialogShown && isNeedToRouteSettings) { resumeTimes.clear() mindboxLogI("System dialog not shown because timeout=$duration -> open settings") mindboxNotificationManager.openNotificationSettings(this) @@ -94,16 +104,29 @@ internal class PushActivationActivity : Activity() { requestPermissionManager.decreaseRequestCounter() } shouldCheckDialogShowing = false - finish() + finishWithResult(isGranted = false, dialogShown = dialogShown) } super.onResume() } override fun onTouchEvent(event: MotionEvent): Boolean { if (event.action == MotionEvent.ACTION_DOWN) { - finish() + finishWithResult(isGranted = false) return true } return super.onTouchEvent(event) } + + override fun onDestroy() { + if (!isResultSent && isFinishing && !isChangingConfigurations) { + finishWithResult(false) + } + super.onDestroy() + } + + private fun finishWithResult(isGranted: Boolean, dialogShown: Boolean = true) { + RuntimePermissionRequestBridge.resolve(requestId.orEmpty(), isGranted, dialogShown) + isResultSent = true + finish() + } } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/actions/RuntimePermissionRequestBridge.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/actions/RuntimePermissionRequestBridge.kt new file mode 100644 index 000000000..1a85caeb5 --- /dev/null +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/actions/RuntimePermissionRequestBridge.kt @@ -0,0 +1,27 @@ +package cloud.mindbox.mobile_sdk.inapp.presentation.actions + +import kotlinx.coroutines.CompletableDeferred +import java.util.concurrent.ConcurrentHashMap + +internal object RuntimePermissionRequestBridge { + + private val pendingRequestsById: MutableMap> = ConcurrentHashMap() + + fun register(requestId: String): CompletableDeferred { + val deferred: CompletableDeferred = CompletableDeferred() + pendingRequestsById[requestId] = deferred + return deferred + } + + fun resolve(requestId: String, isGranted: Boolean, isDialogShown: Boolean) { + val deferred: CompletableDeferred = pendingRequestsById.remove(requestId) ?: return + if (!deferred.isCompleted) { + deferred.complete(PermissionRequest(isGranted, isDialogShown)) + } + } + + data class PermissionRequest( + val isGranted: Boolean, + val dialogShown: Boolean, + ) +} diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/AbstractInAppViewHolder.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/AbstractInAppViewHolder.kt index fd668e799..b3a94c480 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/AbstractInAppViewHolder.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/AbstractInAppViewHolder.kt @@ -8,15 +8,19 @@ import android.view.ViewGroup import android.view.inputmethod.InputMethodManager import android.widget.FrameLayout import android.widget.ImageView +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat import cloud.mindbox.mobile_sdk.R import cloud.mindbox.mobile_sdk.di.mindboxInject +import cloud.mindbox.mobile_sdk.inapp.domain.extensions.sendPresentationFailure +import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.InAppFailureTracker import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppType +import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppTypeWrapper import cloud.mindbox.mobile_sdk.inapp.domain.models.Layer import cloud.mindbox.mobile_sdk.inapp.presentation.InAppCallback import cloud.mindbox.mobile_sdk.inapp.presentation.InAppMessageViewDisplayerImpl import cloud.mindbox.mobile_sdk.inapp.presentation.MindboxView import cloud.mindbox.mobile_sdk.inapp.presentation.actions.InAppActionHandler -import cloud.mindbox.mobile_sdk.logger.mindboxLogE import cloud.mindbox.mobile_sdk.logger.mindboxLogI import cloud.mindbox.mobile_sdk.removeChildById import cloud.mindbox.mobile_sdk.safeAs @@ -28,9 +32,15 @@ import com.bumptech.glide.load.engine.GlideException import com.bumptech.glide.request.RequestListener import com.bumptech.glide.request.target.Target -internal abstract class AbstractInAppViewHolder : InAppViewHolder { +internal abstract class AbstractInAppViewHolder( + final override val wrapper: InAppTypeWrapper, + final override val inAppController: InAppViewHolder.InAppController, + final override val inAppCallback: InAppCallback, +) : InAppViewHolder { protected open var isInAppMessageActive = false + override val isActive: Boolean + get() = isInAppMessageActive private var positionController: InAppPositionController? = null @@ -43,27 +53,35 @@ internal abstract class AbstractInAppViewHolder : InAppViewHolder } private var typingView: View? = null + private var shouldRestoreKeyboard: Boolean = false protected val preparedImages: MutableMap = mutableMapOf() - private val mindboxNotificationManager by mindboxInject { - mindboxNotificationManager - } + internal val inAppFailureTracker: InAppFailureTracker by mindboxInject { inAppFailureTracker } private var inAppActionHandler = InAppActionHandler() + private var backRegistration: BackRegistration? = null + + private fun isKeyboardVisible(root: View): Boolean = + ViewCompat.getRootWindowInsets(root)?.isVisible(WindowInsetsCompat.Type.ime()) == true - private fun hideKeyboard(currentRoot: ViewGroup) { - val context = currentRoot.context - val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager? - if (imm?.isAcceptingText == true) { - typingView = currentRoot.findFocus() - imm.hideSoftInputFromWindow( + protected fun hideKeyboard(currentRoot: ViewGroup) { + typingView = currentRoot.rootView.findFocus() + if (isKeyboardVisible(currentRoot)) { + shouldRestoreKeyboard = true + val context = currentRoot.context + val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager? + imm?.hideSoftInputFromWindow( currentRoot.windowToken, 0 ) } } + protected open fun onBeforeShow(currentRoot: MindboxView) { + hideKeyboard(currentRoot.container) + } + abstract fun bind() protected open fun addUrlSource(layer: Layer.ImageLayer, inAppCallback: InAppCallback) { @@ -94,7 +112,7 @@ internal abstract class AbstractInAppViewHolder : InAppViewHolder if (shouldDismiss) { inAppCallback.onInAppDismissed(wrapper.inAppType.inAppId) mindboxLogI("In-app dismissed by click") - hide() + inAppController.close() } inAppData.onCompleted?.invoke() @@ -116,17 +134,18 @@ internal abstract class AbstractInAppViewHolder : InAppViewHolder isFirstResource: Boolean ): Boolean { return runCatching { - this.mindboxLogE( - message = "Failed to load in-app image with url = $url", - exception = e - ?: RuntimeException("Failed to load in-app image with url = $url") + inAppFailureTracker.sendPresentationFailure( + inAppId = wrapper.inAppType.inAppId, + errorDescription = "Failed to load in-app image with url = $url", + throwable = e ) - hide() + inAppController.close() false - }.getOrElse { - mindboxLogE( - "Unknown error when loading image from cache succeeded", - exception = it + }.getOrElse { throwable -> + inAppFailureTracker.sendPresentationFailure( + inAppId = wrapper.inAppType.inAppId, + errorDescription = "Unknown error after loading image from cache succeeded", + throwable = throwable ) false } @@ -150,10 +169,11 @@ internal abstract class AbstractInAppViewHolder : InAppViewHolder } } false - }.getOrElse { - mindboxLogE( - "Unknown error when loading image from cache failed", - exception = it + }.getOrElse { throwable -> + inAppFailureTracker.sendPresentationFailure( + inAppId = wrapper.inAppType.inAppId, + errorDescription = "Unknown error in onResourceReady callback", + throwable = throwable ) false } @@ -170,34 +190,79 @@ internal abstract class AbstractInAppViewHolder : InAppViewHolder inAppLayout.prepareLayoutForInApp(wrapper.inAppType) } - private fun restoreKeyboard() { - typingView?.let { view -> - view.requestFocus() - val imm = - (view.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager?) - imm?.showSoftInput( - view, - InputMethodManager.SHOW_IMPLICIT - ) + protected fun bindBackAction(currentRoot: MindboxView, onBackPress: () -> Unit) { + clearBackRegistration() + backRegistration = currentRoot.backPressRegistrar.register(inAppLayout, onBackPress) + } + + protected fun clearBackRegistration() { + backRegistration?.unregister() + backRegistration = null + } + + private fun attachToRoot(currentRoot: ViewGroup) { + if (_currentDialog == null) { + initView(currentRoot) + return } + currentRoot.removeChildById(R.id.inapp_layout_container) + _currentDialog?.parent.safeAs()?.removeView(_currentDialog) + currentRoot.addView(currentDialog) } - override fun show(currentRoot: MindboxView) { - isInAppMessageActive = true - initView(currentRoot.container) - val isRepositioningEnabled = currentRoot.container.context.resources.getBoolean(R.bool.mindbox_support_inapp_on_fragment) + private fun startPositionController(currentRoot: ViewGroup) { + positionController?.stop() + positionController = null + val isRepositioningEnabled = currentRoot.context.resources.getBoolean(R.bool.mindbox_support_inapp_on_fragment) positionController = isRepositioningEnabled.takeIf { it }?.run { InAppPositionController().apply { start(currentDialog) } } + } + + protected fun restoreKeyboard() { + val view: View = typingView ?: return + val shouldShowKeyboard: Boolean = shouldRestoreKeyboard + typingView = null + shouldRestoreKeyboard = false + view.post { + view.requestFocus() + if (shouldShowKeyboard) { + val imm = + (view.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager?) + imm?.showSoftInput( + view, + InputMethodManager.SHOW_IMPLICIT + ) + } + } + } + + override fun show(currentRoot: MindboxView) { + isInAppMessageActive = true + attachToRoot(currentRoot.container) + startPositionController(currentRoot.container) + onBeforeShow(currentRoot) + inAppActionHandler.mindboxView = currentRoot + } + + override fun reattach(currentRoot: MindboxView) { + isInAppMessageActive = true + attachToRoot(currentRoot.container) + startPositionController(currentRoot.container) hideKeyboard(currentRoot.container) inAppActionHandler.mindboxView = currentRoot } - override fun hide() { + override fun onClose() { + clearBackRegistration() positionController?.stop() positionController = null currentDialog.parent.safeAs()?.removeView(_currentDialog) - mindboxLogI("hide ${wrapper.inAppType.inAppId} on ${this.hashCode()}") + mindboxLogI("Close ${wrapper.inAppType.inAppId} on ${this.hashCode()}") restoreKeyboard() } + + override fun onStop() { + onClose() + } } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/BackButtonHandler.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/BackButtonHandler.kt index d57d97d12..7ac07e555 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/BackButtonHandler.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/BackButtonHandler.kt @@ -1,22 +1,20 @@ package cloud.mindbox.mobile_sdk.inapp.presentation.view import android.view.KeyEvent -import android.view.View -import android.view.ViewGroup +import cloud.mindbox.mobile_sdk.logger.mindboxLogI internal class BackButtonHandler( - private val viewGroup: ViewGroup, - private val listener: View.OnClickListener?, + private val listener: () -> Unit, ) { - /** Returning "true" or "false" if the event was handled, "null" otherwise. */ + /** + * Returns true if the event was consumed, null if it was not a back key event. + */ fun dispatchKeyEvent(event: KeyEvent?): Boolean? { - if (event != null && event.keyCode == KeyEvent.KEYCODE_BACK && event.action == KeyEvent.ACTION_UP) { - if (listener != null) { - listener.onClick(viewGroup) - return true - } - return false + if (event?.keyCode != KeyEvent.KEYCODE_BACK || event.action != KeyEvent.ACTION_UP || event.isCanceled) { + return null } - return null + mindboxLogI("BackButtonHandler: KEYCODE_BACK ACTION_UP") + listener() + return true } } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/BackButtonLayout.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/BackButtonLayout.kt deleted file mode 100644 index e07afa05b..000000000 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/BackButtonLayout.kt +++ /dev/null @@ -1,7 +0,0 @@ -package cloud.mindbox.mobile_sdk.inapp.presentation.view - -import android.view.View - -internal interface BackButtonLayout { - fun setDismissListener(listener: View.OnClickListener?) -} diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/BackPressRegistrar.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/BackPressRegistrar.kt new file mode 100644 index 000000000..6646f94c7 --- /dev/null +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/BackPressRegistrar.kt @@ -0,0 +1,47 @@ +package cloud.mindbox.mobile_sdk.inapp.presentation.view + +import android.app.Activity +import android.os.Build +import android.window.OnBackInvokedCallback +import android.window.OnBackInvokedDispatcher +import cloud.mindbox.mobile_sdk.logger.mindboxLogI + +internal fun interface BackRegistration { + fun unregister() +} + +internal interface BackPressRegistrar { + fun register(layout: BackButtonLayout, onBackPress: () -> Unit): BackRegistration +} + +internal class ActivityBackPressRegistrar( + private val activityProvider: () -> Activity?, +) : BackPressRegistrar { + + override fun register(layout: BackButtonLayout, onBackPress: () -> Unit): BackRegistration { + layout.setBackListener(onBackPress) + val systemBackRegistration: BackRegistration = registerSystemBackCallback(onBackPress) + return BackRegistration { + layout.setBackListener(null) + systemBackRegistration.unregister() + } + } + + private fun registerSystemBackCallback(onBackPress: () -> Unit): BackRegistration { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + return BackRegistration {} + } + val activity: Activity = activityProvider() ?: return BackRegistration {} + val callback = OnBackInvokedCallback { + mindboxLogI("OnBackInvokedCallback fired") + onBackPress() + } + activity.onBackInvokedDispatcher.registerOnBackInvokedCallback( + OnBackInvokedDispatcher.PRIORITY_DEFAULT, + callback + ) + return BackRegistration { + activity.onBackInvokedDispatcher.unregisterOnBackInvokedCallback(callback) + } + } +} diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/DataCollector.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/DataCollector.kt new file mode 100644 index 000000000..239593bdf --- /dev/null +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/DataCollector.kt @@ -0,0 +1,179 @@ +package cloud.mindbox.mobile_sdk.inapp.presentation.view + +import android.content.Context +import cloud.mindbox.mobile_sdk.BuildConfig +import cloud.mindbox.mobile_sdk.inapp.data.managers.SessionStorageManager +import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.PermissionManager +import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.PermissionStatus +import cloud.mindbox.mobile_sdk.models.Configuration +import cloud.mindbox.mobile_sdk.models.InAppEventType +import cloud.mindbox.mobile_sdk.repository.MindboxPreferences +import cloud.mindbox.mobile_sdk.utils.Constants +import com.google.gson.Gson +import com.google.gson.JsonElement +import com.google.gson.JsonObject +import com.google.gson.JsonPrimitive +import java.util.Locale +import kotlin.math.roundToInt +import android.content.res.Configuration as UiConfiguration + +internal class DataCollector( + private val appContext: Context, + private val sessionStorageManager: SessionStorageManager, + private val permissionManager: PermissionManager, + private val configuration: Configuration, + private val params: Map, + private val inAppInsets: InAppInsets, + private val gson: Gson, + private val inAppId: String, +) { + + private val providers: MutableMap by lazy { + mutableMapOf( + KEY_DEVICE_UUID to Provider.string(MindboxPreferences.deviceUuid), + KEY_LOCAL_STATE_VERSION to Provider.number(MindboxPreferences.localStateVersion), + KEY_ENDPOINT_ID to Provider.string(configuration.endpointId), + KEY_FIRST_INITIALIZATION_TIME to Provider.string(MindboxPreferences.firstInitializationTime), + KEY_IN_APP_ID to Provider.string(inAppId), + KEY_INSETS to createInsetsPayload(inAppInsets), + KEY_LOCALE to Provider.string(resolveLocale()), + KEY_OPERATION_NAME to Provider.string((sessionStorageManager.inAppTriggerEvent as? InAppEventType.OrdinalEvent)?.name), + KEY_OPERATION_BODY to Provider.string((sessionStorageManager.inAppTriggerEvent as? InAppEventType.OrdinalEvent)?.body), + KEY_PERMISSIONS to createPermissionsPayload(), + KEY_PLATFORM to Provider.string(VALUE_PLATFORM), + KEY_SDK_VERSION to Provider.string(BuildConfig.VERSION_NAME), + KEY_SDK_VERSION_NUMERIC to Provider.string(Constants.SDK_VERSION_NUMERIC.toString()), + KEY_THEME to Provider.string(resolveTheme()), + KEY_TRACK_VISIT_SOURCE to Provider.string(sessionStorageManager.lastTrackVisitData?.source), + KEY_TRACK_VISIT_REQUEST_URL to Provider.string(sessionStorageManager.lastTrackVisitData?.requestUrl), + KEY_USER_VISIT_COUNT to Provider.string(MindboxPreferences.userVisitCount.toString()), + KEY_VERSION to Provider.string(configuration.versionName), + ).apply { + params.forEach { (key, value) -> + put(key, Provider.string(value)) + } + } + } + + companion object Companion { + private const val KEY_DEVICE_UUID = "deviceUUID" + private const val KEY_ENDPOINT_ID = "endpointId" + private const val KEY_FIRST_INITIALIZATION_TIME = "firstInitializationDateTime" + private const val KEY_IN_APP_ID = "inAppId" + private const val KEY_INSETS = "insets" + private const val KEY_LOCALE = "locale" + private const val KEY_OPERATION_BODY = "operationBody" + private const val KEY_OPERATION_NAME = "operationName" + private const val KEY_PERMISSIONS = "permissions" + private const val KEY_PERMISSIONS_STATUS = "status" + private const val KEY_PERMISSIONS_CAMERA = "camera" + private const val KEY_PERMISSIONS_LOCATION = "location" + private const val KEY_PERMISSIONS_MICROPHONE = "microphone" + private const val KEY_PERMISSIONS_NOTIFICATIONS = "notifications" + private const val KEY_PERMISSIONS_PHOTO_LIBRARY = "photoLibrary" + private const val KEY_PLATFORM = "platform" + private const val KEY_SDK_VERSION = "sdkVersion" + private const val KEY_SDK_VERSION_NUMERIC = "sdkVersionNumeric" + private const val KEY_THEME = "theme" + private const val KEY_TRACK_VISIT_SOURCE = "trackVisitSource" + private const val KEY_TRACK_VISIT_REQUEST_URL = "trackVisitRequestUrl" + private const val KEY_USER_VISIT_COUNT = "userVisitCount" + private const val KEY_VERSION = "version" + private const val KEY_LOCAL_STATE_VERSION = "localStateVersion" + private const val VALUE_PLATFORM = "android" + private const val VALUE_THEME_DARK = "dark" + private const val VALUE_THEME_LIGHT = "light" + } + + internal fun interface Provider { + fun get(): JsonElement? + + companion object { + fun string(value: String?) = Provider { + if (value.isNullOrBlank()) return@Provider null + JsonPrimitive(value) + } + + fun number(value: Number) = Provider { + JsonPrimitive(value) + } + + fun objectIntParams(vararg pairs: Pair) = Provider { + JsonObject().apply { + pairs.forEach { (key, value) -> + addProperty(key, value) + } + } + } + + fun objectStringParams(vararg pairs: Pair) = Provider { + JsonObject().apply { + pairs.forEach { (key, value) -> + addProperty(key, value) + } + } + } + + fun objectStringParams(map: Map) = Provider { + JsonObject().apply { + map.forEach { (key, value) -> + addProperty(key, value) + } + } + } + } + } + + internal fun get(): String { + val payload = JsonObject() + providers.forEach { (key, provider) -> + provider.get()?.let { value -> + payload.add(key, value) + } + } + return gson.toJson(payload) + } + + private fun createPermissionsPayload(): Provider { + val map = mapOf( + KEY_PERMISSIONS_CAMERA to permissionManager.getCameraPermissionStatus().value, + KEY_PERMISSIONS_LOCATION to permissionManager.getLocationPermissionStatus().value, + KEY_PERMISSIONS_MICROPHONE to permissionManager.getMicrophonePermissionStatus().value, + KEY_PERMISSIONS_NOTIFICATIONS to permissionManager.getNotificationPermissionStatus().value, + KEY_PERMISSIONS_PHOTO_LIBRARY to permissionManager.getPhotoLibraryPermissionStatus().value, + ).filter { (_, value) -> value == PermissionStatus.GRANTED.value } + + return Provider { + JsonObject().apply { + map.forEach { (key, value) -> + add(key, JsonObject().apply { addProperty(KEY_PERMISSIONS_STATUS, value) }) + } + } + } + } + + private fun resolveTheme(): String { + val uiMode: Int = appContext.resources.configuration.uiMode + val isDarkTheme: Boolean = (uiMode and UiConfiguration.UI_MODE_NIGHT_MASK) == UiConfiguration.UI_MODE_NIGHT_YES + return if (isDarkTheme) VALUE_THEME_DARK else VALUE_THEME_LIGHT + } + + private fun resolveLocale(): String { + return Locale.getDefault().toLanguageTag().replace("-", "_") + } + + private fun createInsetsPayload(insets: InAppInsets): Provider { + val density: Float = appContext.resources.displayMetrics.density + + fun Int.toCssPixel(): Int = (this / density).roundToInt() + + return Provider { + JsonObject().apply { + addProperty(InAppInsets.BOTTOM, insets.bottom.toCssPixel()) + addProperty(InAppInsets.LEFT, insets.left.toCssPixel()) + addProperty(InAppInsets.RIGHT, insets.right.toCssPixel()) + addProperty(InAppInsets.TOP, insets.top.toCssPixel()) + } + } + } +} diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/HapticFeedbackExecutor.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/HapticFeedbackExecutor.kt new file mode 100644 index 000000000..f771b0110 --- /dev/null +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/HapticFeedbackExecutor.kt @@ -0,0 +1,224 @@ +package cloud.mindbox.mobile_sdk.inapp.presentation.view + +import android.content.Context +import android.os.Build +import android.os.VibrationEffect +import android.os.Vibrator +import android.os.VibratorManager +import cloud.mindbox.mobile_sdk.annotations.InternalMindboxApi +import cloud.mindbox.mobile_sdk.logger.mindboxLogI +import cloud.mindbox.mobile_sdk.utils.loggingRunCatching +import org.json.JSONObject + +internal enum class HapticImpactStyle { Light, Medium, Heavy } + +internal sealed class HapticRequest { + object Selection : HapticRequest() + + data class Impact(val style: HapticImpactStyle) : HapticRequest() + + data class Pattern(val events: List) : HapticRequest() +} + +/** + * Represents a single haptic pattern event. + * + * @param time Start time of the event relative to the beginning of the pattern, in milliseconds. + * @param duration Duration of the vibration, in milliseconds. + * @param intensity Normalized intensity in range [0.0, 1.0]. + * @param sharpness Normalized sharpness in range [0.0, 1.0]. + * + * Note: On Android, [sharpness] is currently parsed for compatibility with the + * cross‑platform schema but is not applied when generating vibration effects. + * Changes to this parameter will not affect the resulting haptic feedback on Android. + */ +internal data class HapticPatternEvent( + val time: Long, + val duration: Long, + val intensity: Float, + val sharpness: Float, +) + +internal object HapticConstants { + const val KEY_TYPE = "type" + const val KEY_STYLE = "style" + const val KEY_PATTERN = "pattern" + const val KEY_TIME = "time" + const val KEY_DURATION = "duration" + const val KEY_INTENSITY = "intensity" + const val KEY_SHARPNESS = "sharpness" + + const val TYPE_SELECTION = "selection" + const val TYPE_IMPACT = "impact" + const val TYPE_PATTERN = "pattern" + + const val STYLE_LIGHT = "light" + const val STYLE_MEDIUM = "medium" + const val STYLE_HEAVY = "heavy" + const val STYLE_SOFT = "soft" + const val STYLE_RIGID = "rigid" + + const val SELECTION_FALLBACK_DURATION_MS = 20L + const val TRANSIENT_DURATION_MS = 10L +} + +@OptIn(InternalMindboxApi::class) +internal fun parseHapticRequest(payload: String?): HapticRequest { + if (payload.isNullOrBlank() || payload == BridgeMessage.EMPTY_PAYLOAD) { + return HapticRequest.Selection + } + return loggingRunCatching(defaultValue = HapticRequest.Selection) { + val json = JSONObject(payload) + when (json.optString(HapticConstants.KEY_TYPE, HapticConstants.TYPE_SELECTION)) { + HapticConstants.TYPE_IMPACT -> { + val styleStr: String = json.optString(HapticConstants.KEY_STYLE) + HapticRequest.Impact(style = parseImpactStyle(styleStr)) + } + HapticConstants.TYPE_PATTERN -> HapticRequest.Pattern(events = parsePatternEvents(json)) + else -> HapticRequest.Selection + } + } +} + +private fun parseImpactStyle(style: String): HapticImpactStyle = when (style) { + HapticConstants.STYLE_LIGHT, HapticConstants.STYLE_SOFT -> HapticImpactStyle.Light + HapticConstants.STYLE_HEAVY, HapticConstants.STYLE_RIGID -> HapticImpactStyle.Heavy + else -> HapticImpactStyle.Medium +} + +private fun parsePatternEvents(json: JSONObject): List { + val array = json.optJSONArray(HapticConstants.KEY_PATTERN) ?: return emptyList() + return (0 until array.length()).mapNotNull { index -> + loggingRunCatching(defaultValue = null) { + val item = array.getJSONObject(index) + HapticPatternEvent( + time = item.optLong(HapticConstants.KEY_TIME, 0L), + duration = item.optLong(HapticConstants.KEY_DURATION, 0L), + intensity = item.optDouble(HapticConstants.KEY_INTENSITY, 1.0).toFloat(), + sharpness = item.optDouble(HapticConstants.KEY_SHARPNESS, 0.0).toFloat(), + ) + } + } +} + +internal interface HapticFeedbackExecutor { + fun execute(request: HapticRequest) + + fun cancel() +} + +internal class HapticFeedbackExecutorImpl( + private val context: Context, +) : HapticFeedbackExecutor { + + override fun execute(request: HapticRequest) { + loggingRunCatching { + when (request) { + is HapticRequest.Selection -> executeSelection() + is HapticRequest.Impact -> executeImpact(request.style) + is HapticRequest.Pattern -> executePattern(request.events) + } + } + } + + override fun cancel() { + loggingRunCatching { + resolveVibrator()?.cancel() + } + } + + @Suppress("DEPRECATION") + private fun executeSelection() { + val vibrator: Vibrator = resolveVibrator() ?: return + when { + Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q -> + vibrator.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_TICK)) + Build.VERSION.SDK_INT >= Build.VERSION_CODES.O -> + vibrator.vibrate(VibrationEffect.createOneShot(HapticConstants.SELECTION_FALLBACK_DURATION_MS, 85)) + else -> + vibrator.vibrate(HapticConstants.SELECTION_FALLBACK_DURATION_MS) + } + } + + @Suppress("DEPRECATION") + private fun executeImpact(style: HapticImpactStyle) { + val vibrator: Vibrator = resolveVibrator() ?: return + when { + Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q -> { + val effectId: Int = when (style) { + HapticImpactStyle.Light -> VibrationEffect.EFFECT_TICK + HapticImpactStyle.Medium -> VibrationEffect.EFFECT_CLICK + HapticImpactStyle.Heavy -> VibrationEffect.EFFECT_HEAVY_CLICK + } + vibrator.vibrate(VibrationEffect.createPredefined(effectId)) + } + Build.VERSION.SDK_INT >= Build.VERSION_CODES.O -> { + val (durationMs, amplitude) = impactParams(style) + vibrator.vibrate(VibrationEffect.createOneShot(durationMs, amplitude)) + } + else -> + vibrator.vibrate(impactLegacyDuration(style)) + } + } + + @Suppress("DEPRECATION") + private fun executePattern(events: List) { + if (events.isEmpty()) return + val vibrator: Vibrator = resolveVibrator() ?: return + mindboxLogI("[Haptic] pattern events=${events.size}") + val (timings, amplitudes) = buildWaveform(events) + if (timings.isEmpty()) return + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + vibrator.vibrate(VibrationEffect.createWaveform(timings.toLongArray(), amplitudes.toIntArray(), -1)) + } else { + @Suppress("DEPRECATION") + vibrator.vibrate(timings.toLongArray(), -1) + } + } + + private fun buildWaveform(events: List): Pair, List> { + val sorted: List = events.sortedBy { it.time } + val timings: MutableList = mutableListOf() + val amplitudes: MutableList = mutableListOf() + var currentTime = 0L + for (event in sorted) { + val effectiveDuration: Long = + if (event.duration > 0) event.duration else HapticConstants.TRANSIENT_DURATION_MS + val amplitude: Int = (event.intensity * 255).toInt().coerceIn(0, 255) + val gap: Long = event.time - currentTime + if (gap > 0) { + timings.add(gap) + amplitudes.add(0) + } else if (timings.isEmpty()) { + timings.add(0) + amplitudes.add(0) + } + timings.add(effectiveDuration) + amplitudes.add(amplitude) + currentTime = event.time + effectiveDuration + } + return timings to amplitudes + } + + @Suppress("DEPRECATION") + private fun resolveVibrator(): Vibrator? { + val vibrator: Vibrator? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + (context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as? VibratorManager)?.defaultVibrator + } else { + context.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator + } + return vibrator?.takeIf { it.hasVibrator() } + } + + private fun impactParams(style: HapticImpactStyle): Pair = when (style) { + HapticImpactStyle.Light -> 20L to 85 + HapticImpactStyle.Medium -> 40L to 180 + HapticImpactStyle.Heavy -> 60L to 255 + } + + private fun impactLegacyDuration(style: HapticImpactStyle): Long = when (style) { + HapticImpactStyle.Light -> 20L + HapticImpactStyle.Medium -> 40L + HapticImpactStyle.Heavy -> 60L + } +} diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/InAppConstraintLayout.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/InAppConstraintLayout.kt index 7363418e0..aa4367465 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/InAppConstraintLayout.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/InAppConstraintLayout.kt @@ -17,13 +17,32 @@ import kotlin.math.abs internal class InAppConstraintLayout : ConstraintLayout, BackButtonLayout { - private var backButtonHandler: BackButtonHandler? = null - fun setSwipeToDismissCallback(callback: () -> Unit) { swipeToDismissCallback = callback } + override fun setBackListener(listener: (() -> Unit)?) { + backButtonHandler = listener?.let { BackButtonHandler(it) } + } + private var swipeToDismissCallback: (() -> Unit)? = null + private var backButtonHandler: BackButtonHandler? = null + + override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean = + if (keyCode == KeyEvent.KEYCODE_BACK && backButtonHandler != null) { + true + } else { + super.onKeyDown(keyCode, event) + } + + override fun dispatchKeyEvent(event: KeyEvent?): Boolean = + if (backButtonHandler?.dispatchKeyEvent(event) == true) { + true + } else { + super.dispatchKeyEvent(event) + } + + internal var webViewInsets: InAppInsets = InAppInsets() constructor(context: Context) : super(context) constructor(context: Context, attrs: AttributeSet) : super(context, attrs) @@ -204,14 +223,23 @@ internal class InAppConstraintLayout : ConstraintLayout, BackButtonLayout { val inset = windowInset.getInsets( WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout() - or WindowInsetsCompat.Type.ime() + or WindowInsetsCompat.Type.navigationBars() ) - view.updatePadding( + webViewInsets = InAppInsets( + left = inset.left, + top = inset.top, + right = inset.right, bottom = maxOf(inset.bottom, getNavigationBarHeight()) ) + + view.updatePadding( + bottom = windowInset.getInsets( + WindowInsetsCompat.Type.ime() + ).bottom + ) mindboxLogI("Webview Insets: $inset") - WindowInsetsCompat.CONSUMED + windowInset } } @@ -245,20 +273,22 @@ internal class InAppConstraintLayout : ConstraintLayout, BackButtonLayout { ) : super( context, attrs, defStyleAttr, defStyleRes ) +} - override fun setDismissListener(listener: OnClickListener?) { - backButtonHandler = BackButtonHandler(this, listener) +internal data class InAppInsets( + val left: Int = 0, + val top: Int = 0, + val right: Int = 0, + val bottom: Int = 0 +) { + companion object { + const val LEFT = "left" + const val TOP = "top" + const val RIGHT = "right" + const val BOTTOM = "bottom" } +} - override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean = - if (keyCode == KeyEvent.KEYCODE_BACK && backButtonHandler != null) { - true - } else { - super.onKeyDown(keyCode, event) - } - - override fun dispatchKeyEvent(event: KeyEvent?): Boolean { - val handled = backButtonHandler?.dispatchKeyEvent(event) - return handled ?: super.dispatchKeyEvent(event) - } +internal fun interface BackButtonLayout { + fun setBackListener(listener: (() -> Unit)?) } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/InAppViewHolder.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/InAppViewHolder.kt index c7424a93f..927212e66 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/InAppViewHolder.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/InAppViewHolder.kt @@ -2,17 +2,33 @@ package cloud.mindbox.mobile_sdk.inapp.presentation.view import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppType import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppTypeWrapper +import cloud.mindbox.mobile_sdk.inapp.presentation.InAppCallback import cloud.mindbox.mobile_sdk.inapp.presentation.MindboxView internal interface InAppViewHolder { val wrapper: InAppTypeWrapper + val inAppController: InAppController + + val inAppCallback: InAppCallback + val isActive: Boolean fun show(currentRoot: MindboxView) - fun hide() + fun reattach(currentRoot: MindboxView) { + show(currentRoot) + } + + fun canReuseOnRestore(inAppId: String): Boolean = false + + fun onClose() + + fun onStop() + + fun interface InAppController { - fun release() {} + fun close() + } } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/ModalWindowInAppViewHolder.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/ModalWindowInAppViewHolder.kt index 53bc685ae..baebe0328 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/ModalWindowInAppViewHolder.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/ModalWindowInAppViewHolder.kt @@ -16,21 +16,14 @@ import cloud.mindbox.mobile_sdk.logger.mindboxLogI import cloud.mindbox.mobile_sdk.removeChildById internal class ModalWindowInAppViewHolder( - override val wrapper: InAppTypeWrapper, - private val inAppCallback: InAppCallback, -) : AbstractInAppViewHolder() { + wrapper: InAppTypeWrapper, + controller: InAppViewHolder.InAppController, + inAppCallback: InAppCallback, +) : AbstractInAppViewHolder(wrapper, controller, inAppCallback) { private var currentBackground: ViewGroup? = null - override val isActive: Boolean - get() = isInAppMessageActive - override fun bind() { - inAppLayout.setDismissListener { - inAppCallback.onInAppDismissed(wrapper.inAppType.inAppId) - mindboxLogI("In-app dismissed by dialog click") - hide() - } wrapper.inAppType.elements.forEach { element -> when (element) { is Element.CloseButton -> { @@ -39,9 +32,9 @@ internal class ModalWindowInAppViewHolder( element ).apply { setOnClickListener { - mindboxLogI("In-app dismissed by close click") inAppCallback.onInAppDismissed(wrapper.inAppType.inAppId) - hide() + mindboxLogI("In-app dismissed by close click") + inAppController.close() } } inAppLayout.addView(inAppCrossView) @@ -53,7 +46,7 @@ internal class ModalWindowInAppViewHolder( setOnClickListener { inAppCallback.onInAppDismissed(wrapper.inAppType.inAppId) mindboxLogI("In-app dismissed by background click") - hide() + inAppController.close() } isVisible = true @@ -88,6 +81,12 @@ internal class ModalWindowInAppViewHolder( } mindboxLogI("Show ${wrapper.inAppType.inAppId} on ${this.hashCode()}") currentDialog.requestFocus() + val backAction = { + inAppCallback.onInAppDismissed(wrapper.inAppType.inAppId) + mindboxLogI("In-app dismissed by back press") + inAppController.close() + } + bindBackAction(currentRoot, backAction) } override fun initView(currentRoot: ViewGroup) { diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/SnackbarInAppViewHolder.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/SnackbarInAppViewHolder.kt index 87a86f856..49ce7af4e 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/SnackbarInAppViewHolder.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/SnackbarInAppViewHolder.kt @@ -11,14 +11,12 @@ import cloud.mindbox.mobile_sdk.logger.mindboxLogI import cloud.mindbox.mobile_sdk.px internal class SnackbarInAppViewHolder( - override val wrapper: InAppTypeWrapper, - private val inAppCallback: InAppCallback, + wrapper: InAppTypeWrapper, + controller: InAppViewHolder.InAppController, + inAppCallback: InAppCallback, private val inAppImageSizeStorage: InAppImageSizeStorage, private val isFirstShow: Boolean = true, -) : AbstractInAppViewHolder() { - - override val isActive: Boolean - get() = isInAppMessageActive +) : AbstractInAppViewHolder(wrapper, controller, inAppCallback) { private var requiredSizes: HashMap = HashMap() @@ -42,7 +40,7 @@ internal class SnackbarInAppViewHolder( super.initView(currentRoot) inAppLayout.setSwipeToDismissCallback { mindboxLogI("In-app dismissed by swipe") - hideWithAnimation() + closeWithAnimation() } } @@ -85,7 +83,7 @@ internal class SnackbarInAppViewHolder( val inAppCrossView = InAppCrossView(currentDialog.context, element).apply { setOnClickListener { mindboxLogI("In-app dismissed by close click") - hideWithAnimation() + closeWithAnimation() } } inAppLayout.addView(inAppCrossView) @@ -101,17 +99,17 @@ internal class SnackbarInAppViewHolder( } } - private fun hideWithAnimation() { + private fun closeWithAnimation() { inAppCallback.onInAppDismissed(wrapper.inAppType.inAppId) when (wrapper.inAppType.position.gravity.vertical) { SnackbarPosition.TOP -> inAppLayout.slideDown( isReverse = true, - onAnimationEnd = ::hide + onAnimationEnd = inAppController::close ) SnackbarPosition.BOTTOM -> inAppLayout.slideUp( isReverse = true, - onAnimationEnd = ::hide + onAnimationEnd = inAppController::close ) } } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebAppInterface.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebAppInterface.kt deleted file mode 100644 index f87e0f1f3..000000000 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebAppInterface.kt +++ /dev/null @@ -1,29 +0,0 @@ -package cloud.mindbox.mobile_sdk.inapp.presentation.view - -import android.annotation.SuppressLint -import android.webkit.JavascriptInterface -import cloud.mindbox.mobile_sdk.logger.mindboxLogI - -@SuppressLint("JavascriptInterface", "UNUSED") -internal class WebAppInterface( - private val paramsProvider: ParamProvider, - private val onAction: (String, String) -> Unit -) { - - @JavascriptInterface - fun receiveParam(key: String): String? { - return paramsProvider.get(key).also { - mindboxLogI("Call receiveParam key: $key, return: $it") - } - } - - @JavascriptInterface - fun postMessage(action: String, data: String) { - mindboxLogI("Call postMessage action: $action, data: $data") - onAction(action, data) - } -} - -internal fun interface ParamProvider { - fun get(key: String): String? -} diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewAction.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewAction.kt new file mode 100644 index 000000000..ce5397664 --- /dev/null +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewAction.kt @@ -0,0 +1,195 @@ +package cloud.mindbox.mobile_sdk.inapp.presentation.view + +import cloud.mindbox.mobile_sdk.annotations.InternalMindboxApi +import cloud.mindbox.mobile_sdk.logger.mindboxLogW +import com.google.gson.annotations.SerializedName +import java.util.UUID + +@InternalMindboxApi +public enum class WebViewAction { + @SerializedName("init") + INIT, + + @SerializedName("ready") + READY, + + @SerializedName("click") + CLICK, + + @SerializedName("close") + CLOSE, + + @SerializedName("hide") + HIDE, + + @SerializedName("back") + BACK, + + @SerializedName("log") + LOG, + + @SerializedName("alert") + ALERT, + + @SerializedName("toast") + TOAST, + + @SerializedName("syncOperation") + SYNC_OPERATION, + + @SerializedName("asyncOperation") + ASYNC_OPERATION, + + @SerializedName("openLink") + OPEN_LINK, + + @SerializedName("navigationIntercepted") + NAVIGATION_INTERCEPTED, + + @SerializedName("localState.get") + LOCAL_STATE_GET, + + @SerializedName("localState.set") + LOCAL_STATE_SET, + + @SerializedName("localState.init") + LOCAL_STATE_INIT, + + @SerializedName("haptic") + HAPTIC, + + @SerializedName(value = "permission.request") + PERMISSION_REQUEST, + + @SerializedName(value = "settings.open") + SETTINGS_OPEN, + + @SerializedName("motion.start") + MOTION_START, + + @SerializedName("motion.stop") + MOTION_STOP, + + @SerializedName("motion.event") + MOTION_EVENT, +} + +@InternalMindboxApi +public sealed class BridgeMessage { + public abstract val version: Int + public abstract val type: String + public abstract val action: WebViewAction + public abstract val payload: String? + public abstract val id: String + public abstract val timestamp: Long + + public data class Request( + override val version: Int, + override val action: WebViewAction, + override val payload: String?, + override val id: String, + override val timestamp: Long, + override val type: String = TYPE_REQUEST, + ) : BridgeMessage() + + public data class Response( + override val version: Int, + override val action: WebViewAction, + override val payload: String?, + override val id: String, + override val timestamp: Long, + override val type: String = TYPE_RESPONSE, + ) : BridgeMessage() + + public data class Error( + override val version: Int, + override val action: WebViewAction, + override val payload: String?, + override val id: String, + override val timestamp: Long, + override val type: String = TYPE_ERROR, + ) : BridgeMessage() + + public companion object { + public const val VERSION: Int = 1 + public const val EMPTY_PAYLOAD: String = "{}" + public const val SUCCESS_PAYLOAD: String = """{"success":true}""" + public const val UNKNOWN_ERROR_PAYLOAD: String = """{"error":"Unknown error"}""" + public const val TYPE_FIELD_NAME: String = "type" + public const val TYPE_REQUEST: String = "request" + public const val TYPE_RESPONSE: String = "response" + public const val TYPE_ERROR: String = "error" + + public fun createAction(action: WebViewAction, payload: String): Request = + Request( + id = UUID.randomUUID().toString(), + version = VERSION, + action = action, + payload = payload, + timestamp = System.currentTimeMillis(), + ) + + public fun createResponseAction(message: Request, payload: String?): Response = + Response( + id = message.id, + version = message.version, + action = message.action, + payload = payload, + timestamp = System.currentTimeMillis(), + ) + + public fun createErrorAction(message: Request, payload: String?): Error = + Error( + id = message.id, + version = message.version, + action = message.action, + payload = payload, + timestamp = System.currentTimeMillis(), + ) + } +} + +@InternalMindboxApi +internal typealias BridgeMessageHandler = (BridgeMessage.Request) -> String + +@InternalMindboxApi +internal typealias BridgeSuspendMessageHandler = suspend (BridgeMessage.Request) -> String + +@InternalMindboxApi +internal class WebViewActionHandlers { + + private val handlersByActionValue: MutableMap = mutableMapOf() + private val suspendHandlersByActionValue: MutableMap = mutableMapOf() + + fun register(actionValue: WebViewAction, handler: BridgeMessageHandler) { + if (handlersByActionValue.containsKey(actionValue)) { + mindboxLogW("Handler for action $actionValue already registered") + } + handlersByActionValue[actionValue] = handler + } + + fun registerSuspend(actionValue: WebViewAction, handler: BridgeSuspendMessageHandler) { + if (suspendHandlersByActionValue.containsKey(actionValue)) { + mindboxLogW("Suspend handler for action $actionValue already registered") + } + suspendHandlersByActionValue[actionValue] = handler + } + + fun hasSuspendHandler(actionValue: WebViewAction): Boolean { + return suspendHandlersByActionValue.containsKey(actionValue) + } + + fun handleRequest(message: BridgeMessage.Request): Result { + return runCatching { + handlersByActionValue[message.action]?.invoke(message) + ?: throw IllegalArgumentException("No handler for action ${message.action}") + } + } + + suspend fun handleRequestSuspend(message: BridgeMessage.Request): Result { + return runCatching { + suspendHandlersByActionValue[message.action]?.invoke(message) + ?: throw IllegalArgumentException("No suspend handler for action ${message.action}") + } + } +} diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt index 667cf775c..41d8ba2ea 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt @@ -1,349 +1,810 @@ package cloud.mindbox.mobile_sdk.inapp.presentation.view -import android.annotation.SuppressLint -import android.graphics.Color -import android.os.Build -import android.view.KeyEvent +import android.app.Activity +import android.app.Application +import android.net.Uri import android.view.ViewGroup -import android.webkit.* import android.widget.RelativeLayout -import androidx.core.view.isInvisible -import androidx.core.view.isVisible -import cloud.mindbox.mobile_sdk.BuildConfig -import cloud.mindbox.mobile_sdk.Mindbox +import android.widget.Toast +import androidx.appcompat.app.AlertDialog +import androidx.core.net.toUri +import cloud.mindbox.mobile_sdk.* +import cloud.mindbox.mobile_sdk.annotations.InternalMindboxApi import cloud.mindbox.mobile_sdk.di.mindboxInject -import cloud.mindbox.mobile_sdk.fromJson import cloud.mindbox.mobile_sdk.inapp.data.dto.BackgroundDto +import cloud.mindbox.mobile_sdk.inapp.data.managers.SessionStorageManager +import cloud.mindbox.mobile_sdk.inapp.data.validators.BridgeMessageValidator +import cloud.mindbox.mobile_sdk.inapp.data.validators.HapticRequestValidator +import cloud.mindbox.mobile_sdk.inapp.domain.extensions.executeWithFailureTracking +import cloud.mindbox.mobile_sdk.inapp.domain.extensions.sendFailureWithContext +import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.PermissionManager import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppType import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppTypeWrapper import cloud.mindbox.mobile_sdk.inapp.domain.models.Layer import cloud.mindbox.mobile_sdk.inapp.presentation.InAppCallback +import cloud.mindbox.mobile_sdk.inapp.presentation.MindboxNotificationManager import cloud.mindbox.mobile_sdk.inapp.presentation.MindboxView +import androidx.lifecycle.ProcessLifecycleOwner +import cloud.mindbox.mobile_sdk.utils.TimeProvider +import cloud.mindbox.mobile_sdk.inapp.presentation.view.motion.MotionGesture +import cloud.mindbox.mobile_sdk.inapp.presentation.view.motion.MotionService +import cloud.mindbox.mobile_sdk.inapp.presentation.view.motion.MotionServiceProtocol +import cloud.mindbox.mobile_sdk.inapp.presentation.view.motion.MotionStartResult +import cloud.mindbox.mobile_sdk.inapp.webview.* import cloud.mindbox.mobile_sdk.logger.mindboxLogD import cloud.mindbox.mobile_sdk.logger.mindboxLogE import cloud.mindbox.mobile_sdk.logger.mindboxLogI +import cloud.mindbox.mobile_sdk.logger.mindboxLogW import cloud.mindbox.mobile_sdk.managers.DbManager +import cloud.mindbox.mobile_sdk.managers.GatewayManager import cloud.mindbox.mobile_sdk.models.Configuration import cloud.mindbox.mobile_sdk.models.getShortUserAgent -import cloud.mindbox.mobile_sdk.repository.MindboxPreferences -import cloud.mindbox.mobile_sdk.safeAs -import cloud.mindbox.mobile_sdk.utils.Constants +import cloud.mindbox.mobile_sdk.models.operation.request.FailureReason import cloud.mindbox.mobile_sdk.utils.MindboxUtils.Stopwatch -import com.android.volley.Request -import com.android.volley.RequestQueue -import com.android.volley.toolbox.StringRequest -import com.android.volley.toolbox.Volley -import kotlinx.coroutines.Dispatchers +import cloud.mindbox.mobile_sdk.utils.loggingRunCatching +import com.google.gson.Gson +import com.google.gson.annotations.SerializedName +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import java.lang.ref.WeakReference +import org.json.JSONObject +import java.util.Locale import java.util.Timer -import java.util.TreeMap +import java.util.concurrent.ConcurrentHashMap import kotlin.concurrent.timer +@OptIn(InternalMindboxApi::class) internal class WebViewInAppViewHolder( - override val wrapper: InAppTypeWrapper, - private val inAppCallback: InAppCallback, -) : AbstractInAppViewHolder() { + wrapper: InAppTypeWrapper, + controller: InAppViewHolder.InAppController, + inAppCallback: InAppCallback, +) : AbstractInAppViewHolder(wrapper, controller, inAppCallback) { companion object { - @SuppressLint("StaticFieldLeak") - private var webView: WeakReference = WeakReference(null) private const val INIT_TIMEOUT_MS = 7_000L private const val TIMER = "CLOSE_INAPP_TIMER" + private const val JS_RETURN = "true" + private const val JS_BRIDGE_CLASS = "window.bridgeMessagesHandlers" + private const val JS_BRIDGE = "$JS_BRIDGE_CLASS.emit" + private const val JS_CALL_BRIDGE = "(()=>{try{$JS_BRIDGE(%s);return!0}catch(_){return!1}})()" + private const val JS_CHECK_BRIDGE = "(() => typeof $JS_BRIDGE_CLASS !== 'undefined' && typeof $JS_BRIDGE === 'function')()" + private const val MOTION_GESTURE_KEY = "gesture" + private const val MOTION_GESTURES_KEY = "gestures" } private var closeInappTimer: Timer? = null + private var webViewController: WebViewController? = null + private var currentWebViewOrigin: String? = null - private val gson by mindboxInject { gson } + private var motionService: MotionServiceProtocol? = null - override val isActive: Boolean - get() = isInAppMessageActive + private fun bindWebViewBackAction(currentRoot: MindboxView, controller: WebViewController) { + bindBackAction(currentRoot) { sendBackAction(controller) } + } - override fun bind() { - inAppLayout.setDismissListener { - inAppCallback.onInAppDismissed(wrapper.inAppType.inAppId) - mindboxLogI("In-app dismissed by dialog click") - hide() + private val pendingResponsesById: MutableMap> = + ConcurrentHashMap() + + private val gson: Gson by mindboxInject { this.gson } + private val timeProvider: TimeProvider by mindboxInject { timeProvider } + private val messageValidator: BridgeMessageValidator by lazy { BridgeMessageValidator() } + private val hapticRequestValidator: HapticRequestValidator by lazy { HapticRequestValidator() } + private val gatewayManager: GatewayManager by mindboxInject { gatewayManager } + private val sessionStorageManager: SessionStorageManager by mindboxInject { sessionStorageManager } + private val permissionManager: PermissionManager by mindboxInject { permissionManager } + private val mindboxNotificationManager: MindboxNotificationManager by mindboxInject { mindboxNotificationManager } + private val appContext: Application by mindboxInject { appContext } + private val operationExecutor: WebViewOperationExecutor by lazy { + MindboxWebViewOperationExecutor() + } + private val linkRouter: WebViewLinkRouter by lazy { + MindboxWebViewLinkRouter(appContext) + } + private val localStateStore: WebViewLocalStateStore by lazy { + WebViewLocalStateStore(appContext) + } + private val hapticFeedbackExecutor: HapticFeedbackExecutor by lazy { + HapticFeedbackExecutorImpl(appContext) + } + + private val webViewPermissionRequester: WebViewPermissionRequester by lazy { + WebViewPermissionRequesterImpl( + context = appContext, + permissionManager = permissionManager + ) + } + private var currentMindboxView: MindboxView? = null + + override fun onBeforeShow(currentRoot: MindboxView) = Unit + + override fun bind() {} + + suspend fun sendActionAndAwaitResponse( + controller: WebViewController, + message: BridgeMessage.Request + ): BridgeMessage.Response { + val responseDeferred: CompletableDeferred = CompletableDeferred() + pendingResponsesById[message.id] = responseDeferred + sendActionInternal(controller = controller, message = message) { error -> + if (responseDeferred.isActive) { + responseDeferred.completeExceptionally( + IllegalStateException("Failed to send message ${message.action} to WebView: $error") + ) + } } + return responseDeferred.await() } - private fun addJavascriptInterface(layer: Layer.WebViewLayer, configuration: Configuration) { - webView.get()?.apply { - val params = TreeMap(String.CASE_INSENSITIVE_ORDER).apply { - put("sdkVersion", Mindbox.getSdkVersion()) - put("endpointId", configuration.endpointId) - put("deviceUuid", MindboxPreferences.deviceUuid) - put("sdkVersionNumeric", Constants.SDK_VERSION_NUMERIC.toString()) - putAll(layer.params) + private fun sendActionInternal( + controller: WebViewController, + message: BridgeMessage, + onError: ((String?) -> Unit)? = null + ) { + mindboxLogI("SDK -> send message $message") + val json: String = gson.toJson(message) + val escapedJson: String = JSONObject.quote(json) + controller.evaluateJavaScript(JS_CALL_BRIDGE.format(escapedJson)) { result -> + if (!checkEvaluateJavaScript(result)) { + onError?.invoke(result) } - val provider = ParamProvider { key -> - params[key] + } + } + + private fun createWebViewActionHandlers( + controller: WebViewController, + layer: Layer.WebViewLayer, + configuration: Configuration + ): WebViewActionHandlers { + return WebViewActionHandlers().apply { + register(WebViewAction.CLICK, ::handleClickAction) + register(WebViewAction.CLOSE, ::handleCloseAction) + register(WebViewAction.LOG, ::handleLogAction) + register(WebViewAction.TOAST, ::handleToastAction) + register(WebViewAction.ALERT, ::handleAlertAction) + register(WebViewAction.ASYNC_OPERATION, ::handleAsyncOperationAction) + register(WebViewAction.OPEN_LINK, ::handleOpenLinkAction) + registerSuspend(WebViewAction.SYNC_OPERATION, ::handleSyncOperationAction) + registerSuspend(WebViewAction.LOCAL_STATE_GET, ::handleLocalStateGetAction) + registerSuspend(WebViewAction.LOCAL_STATE_SET, ::handleLocalStateSetAction) + registerSuspend(WebViewAction.LOCAL_STATE_INIT, ::handleLocalStateInitAction) + registerSuspend(WebViewAction.PERMISSION_REQUEST, ::handlePermissionAction) + register(WebViewAction.SETTINGS_OPEN, ::handleSettingsOpenAction) + register(WebViewAction.READY) { + handleReadyAction( + configuration = configuration, + insets = inAppLayout.webViewInsets, + params = layer.params, + inAppId = wrapper.inAppType.inAppId, + ) } - addJavascriptInterface( - WebAppInterface(provider) { action, data -> - handleWebViewAction(action, data, object : WebViewAction { - - override fun onInit() { - // Cancel timeout when init is received - mindboxLogI("WebView initialization completed " + Stopwatch.stop(TIMER)) - closeInappTimer?.cancel() - closeInappTimer = null - - wrapper.inAppActionCallbacks.onInAppShown.onShown() - webView.get()?.isVisible = true - } + register(WebViewAction.INIT) { + handleInitAction(controller) + } + register(WebViewAction.HIDE) { + handleHideAction(controller) + } + register(WebViewAction.HAPTIC, ::handleHapticAction) + register(WebViewAction.MOTION_START, ::handleMotionStartAction) + register(WebViewAction.MOTION_STOP) { handleMotionStopAction() } + } + } - override fun onCompleted(data: String) { - runCatching { - val actionDto = gson.fromJson(data).getOrThrow() - val (url, payload) = when (actionDto) { - is BackgroundDto.LayerDto.ImageLayerDto.ActionDto.RedirectUrlActionDto -> - actionDto.value to actionDto.intentPayload - is BackgroundDto.LayerDto.ImageLayerDto.ActionDto.PushPermissionActionDto -> - "" to actionDto.intentPayload - } - - wrapper.inAppActionCallbacks.onInAppClick.onClick() - inAppCallback.onInAppClick( - wrapper.inAppType.inAppId, - url ?: "", - payload ?: "" - ) - } - mindboxLogI("In-app completed by webview action with data: $data") - } + private fun handleHapticAction(message: BridgeMessage.Request): String { + val request = parseHapticRequest(message.payload) + if (!hapticRequestValidator.isValid(request)) return BridgeMessage.EMPTY_PAYLOAD + hapticFeedbackExecutor.execute(request = request) + return BridgeMessage.EMPTY_PAYLOAD + } - override fun onClose() { - inAppCallback.onInAppDismissed(wrapper.inAppType.inAppId) - mindboxLogI("In-app dismissed by webview action") - hide() - release() - } + private fun handleMotionStartAction(message: BridgeMessage.Request): String { + val payload = requireNotNull(message.payload) { "Missing payload" } + val gestures = parseMotionGestures(payload) + require(gestures.isNotEmpty()) { "No valid gestures provided. Available: shake, flip" } + val result = getOrCreateMotionService().startMonitoring(gestures) + require(!result.allUnavailable) { + "No sensors available for: ${result.unavailable.joinToString { it.value }}" + } + return buildMotionStartPayload(result) + } - override fun onHide() { - webView.get()?.isInvisible = true - } + private fun buildMotionStartPayload(result: MotionStartResult): String { + if (result.unavailable.isEmpty()) return BridgeMessage.SUCCESS_PAYLOAD + return gson.toJson( + MotionStartPayload(unavailable = result.unavailable.map { it.value }) + ) + } - override fun onLog(message: String) { - webView.get()?.mindboxLogI("JS: $message") - } - }) - }, - "SdkBridge" - ) + private fun handleMotionStopAction(): String { + motionService?.stopMonitoring() + return BridgeMessage.SUCCESS_PAYLOAD + } + + private fun sendMotionEvent(gesture: MotionGesture, data: Map) { + val controller: WebViewController = webViewController ?: return + val payload = JSONObject() + .apply { + put(MOTION_GESTURE_KEY, gesture.value) + data.forEach { (key, value) -> put(key, value) } + } + .toString() + val message: BridgeMessage.Request = BridgeMessage.createAction( + action = WebViewAction.MOTION_EVENT, + payload = payload, + ) + sendActionInternal(controller, message) { error -> + mindboxLogW("[WebView] Motion: failed to send motion.event to JS: $error") + motionService?.stopMonitoring() } } - @SuppressLint("SetJavaScriptEnabled") - private fun createWebView(layer: Layer.WebViewLayer): WebView { - mindboxLogI("Creating WebView for In-App: ${wrapper.inAppType.inAppId} with layer ${layer.type}") - return WebView(currentDialog.context).apply { - webViewClient = InAppWebClient( - onCriticalError = { - mindboxLogE("WebView critical error. Destroying In-App.") - release() - } - ) + private fun parseMotionGestures(payload: String): Set { + return loggingRunCatching(defaultValue = emptySet()) { + val array = JSONObject(payload).optJSONArray(MOTION_GESTURES_KEY) + ?: return@loggingRunCatching emptySet() + (0 until array.length()) + .mapNotNull { i -> array.optString(i).enumValue() } + .toSet() + } + } - layoutParams = RelativeLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT - ) - settings.javaScriptEnabled = true - settings.domStorageEnabled = true - settings.loadWithOverviewMode = true - settings.builtInZoomControls = true - settings.displayZoomControls = false - settings.defaultTextEncodingName = "utf-8" - settings.cacheMode = WebSettings.LOAD_NO_CACHE - settings.allowContentAccess = true - setBackgroundColor(Color.TRANSPARENT) - } - } - - @SuppressLint("SetJavaScriptEnabled") - fun addUrlSource(layer: Layer.WebViewLayer) { - if (webView.get() == null) { - WebView.setWebContentsDebuggingEnabled(BuildConfig.DEBUG) - webView = WeakReference(createWebView(layer).also { - it.visibility = ViewGroup.INVISIBLE - }) - Mindbox.mindboxScope.launch { - val configuration = DbManager.listenConfigurations().first() - withContext(Dispatchers.Main) { - addJavascriptInterface(layer, configuration) - } + private fun handleReadyAction( + configuration: Configuration, + insets: InAppInsets, + params: Map, + inAppId: String, + ): String { + return DataCollector( + appContext = appContext, + sessionStorageManager = sessionStorageManager, + permissionManager = permissionManager, + gson = gson, + configuration = configuration, + params = params, + inAppInsets = insets, + inAppId = inAppId, + ).get() + } - webView.get()?.post { - webView.get()?.settings?.userAgentString += " " + configuration.getShortUserAgent() - } - val requestQueue: RequestQueue = Volley.newRequestQueue(currentDialog.context) - val stringRequest = StringRequest( - Request.Method.GET, - layer.contentUrl, - { response -> - webView.get()?.loadDataWithBaseURL( - layer.baseUrl, - response, - "text/html", - "UTF-8", - null - ) + private fun activateFirstShowPresentation( + mindboxView: MindboxView, + controller: WebViewController, + ) { + hideKeyboard(inAppLayout) + inAppLayout.requestFocus() + bindWebViewBackAction(mindboxView, controller) + controller.setVisibility(true) + } - Stopwatch.start(TIMER) - // Start timeout after loading the page - closeInappTimer = timer( - initialDelay = INIT_TIMEOUT_MS, - period = INIT_TIMEOUT_MS, - action = { - webView.get()?.post { - if (closeInappTimer != null) { - mindboxLogE("WebView initialization timed out after ${Stopwatch.stop(TIMER)}.") - release() - } - } - } - ) - }, - { error -> - mindboxLogE("Failed to fetch HTML content for In-App: $error. Destroying.") - release() - } - ) + private fun handleInitAction(controller: WebViewController): String { + stopTimer() + wrapper.inAppActionCallbacks.onInAppShown.onShown() + val mindboxView = currentMindboxView ?: run { + mindboxLogW("MindboxView is null when activating WebView In-App") + inAppController.close() + return BridgeMessage.UNKNOWN_ERROR_PAYLOAD + } + activateFirstShowPresentation( + mindboxView = mindboxView, + controller = controller, + ) + return BridgeMessage.EMPTY_PAYLOAD + } + + private fun handleClickAction(message: BridgeMessage.Request): String { + runCatching { + val actionDto: BackgroundDto.LayerDto.ImageLayerDto.ActionDto = + gson.fromJson(message.payload).getOrThrow() + val actionResult: Pair = when (actionDto) { + is BackgroundDto.LayerDto.ImageLayerDto.ActionDto.RedirectUrlActionDto -> + actionDto.value to actionDto.intentPayload - requestQueue.add(stringRequest) + is BackgroundDto.LayerDto.ImageLayerDto.ActionDto.PushPermissionActionDto -> + "" to actionDto.intentPayload } + val url: String? = actionResult.first + val payload: String? = actionResult.second + inAppCallback.onInAppClick( + wrapper.inAppType.inAppId, + url ?: "", + payload ?: "" + ) + } + mindboxLogI("In-app completed by webview action with data: ${message.payload}") + return BridgeMessage.EMPTY_PAYLOAD + } + + private fun handleCloseAction(message: BridgeMessage): String { + motionService?.stopMonitoring() + inAppCallback.onInAppDismissed(wrapper.inAppType.inAppId) + mindboxLogI("In-app dismissed by webview action ${message.action} with payload ${message.payload}") + inAppController.close() + return BridgeMessage.EMPTY_PAYLOAD + } + + private fun handleHideAction(controller: WebViewController): String { + controller.setVisibility(false) + return BridgeMessage.EMPTY_PAYLOAD + } + + private fun handleLogAction(message: BridgeMessage.Request): String { + mindboxLogI("JS: ${message.payload}") + return BridgeMessage.EMPTY_PAYLOAD + } + + private fun handleToastAction(message: BridgeMessage.Request): String { + webViewController?.view?.context?.let { context -> + Toast.makeText(context, message.payload, Toast.LENGTH_LONG).show() + } + return BridgeMessage.EMPTY_PAYLOAD + } + + private fun handleAlertAction(message: BridgeMessage.Request): String { + webViewController?.view?.context?.let { context -> + AlertDialog.Builder(context) + .setMessage(message.payload) + .setPositiveButton("OK") { dialog, _ -> dialog.dismiss() } + .show() } - webView.get()?.let { view -> - if (view.parent !== inAppLayout) { - view.parent.safeAs()?.removeView(view) - inAppLayout.addView(view) + return BridgeMessage.EMPTY_PAYLOAD + } + + private fun handleAsyncOperationAction(message: BridgeMessage.Request): String { + operationExecutor.executeAsyncOperation(appContext, message.payload) + return BridgeMessage.EMPTY_PAYLOAD + } + + private fun handleOpenLinkAction(message: BridgeMessage.Request): String { + linkRouter.executeOpenLink(message.payload) + .getOrElse { error: Throwable -> + throw IllegalStateException(error.message ?: "Navigation error") } - } ?: release() + return BridgeMessage.SUCCESS_PAYLOAD } - override fun show(currentRoot: MindboxView) { - super.show(currentRoot) - mindboxLogI("Try to show inapp with id ${wrapper.inAppType.inAppId}") - wrapper.inAppType.layers.forEach { layer -> - when (layer) { - is Layer.WebViewLayer -> { - addUrlSource(layer) - } + private suspend fun handleSyncOperationAction(message: BridgeMessage.Request): String { + return operationExecutor.executeSyncOperation(message.payload) + } + + private fun handleLocalStateGetAction(message: BridgeMessage.Request): String { + val payload: String = message.payload ?: BridgeMessage.EMPTY_PAYLOAD + return localStateStore.getState(payload) + } + + private fun handleLocalStateSetAction(message: BridgeMessage.Request): String { + val payload: String = message.payload ?: BridgeMessage.EMPTY_PAYLOAD + return localStateStore.setState(payload) + } + + private fun handleLocalStateInitAction(message: BridgeMessage.Request): String { + val payload: String = message.payload ?: BridgeMessage.EMPTY_PAYLOAD + return localStateStore.initState(payload) + } + + private suspend fun handlePermissionAction(message: BridgeMessage.Request): String { + val payload: String = message.payload ?: BridgeMessage.EMPTY_PAYLOAD + val typeString: String? = JSONObject(payload).getString(PERMISSION_PAYLOAD_TYPE_FIELD_NAME) + val type: PermissionType? = runCatching { typeString.enumValue() }.getOrNull() + requireNotNull(type) { "Unknown permission type: $typeString" } + + val activity: Activity? = webViewController?.view?.context?.safeAs() + checkNotNull(activity) { "Not found activity for permission request" } + + val permissionRequestResult: PermissionActionResponse = webViewPermissionRequester.requestPermission( + activity, + type + ) + return gson.toJson(permissionRequestResult) + } - else -> { - mindboxLogD("Layer is not supported") + private fun handleSettingsOpenAction(message: BridgeMessage.Request): String { + val payload: String = message.payload ?: BridgeMessage.EMPTY_PAYLOAD + val settingsOpenRequest: SettingsOpenRequest? = gson.fromJson(payload).getOrNull() + requireNotNull(settingsOpenRequest) + + val targetType = settingsOpenRequest.target.enumValue() + val activity: Activity? = webViewController?.view?.context?.safeAs() + checkNotNull(activity) { "Not found activity for open settings" } + + when (targetType) { + SettingsOpenTargetType.NOTIFICATIONS -> mindboxNotificationManager.openNotificationSettings(activity, settingsOpenRequest.channelId) + SettingsOpenTargetType.APPLICATION -> mindboxNotificationManager.openApplicationSettings(activity) + } + return BridgeMessage.SUCCESS_PAYLOAD + } + + private fun createWebViewController(layer: Layer.WebViewLayer): WebViewController { + mindboxLogI("Creating WebView for In-App: ${wrapper.inAppType.inAppId} with layer ${layer.type}") + val controller: WebViewController = WebViewController.create(currentDialog.context, BuildConfig.DEBUG) + val view: WebViewPlatformView = controller.view + view.layoutParams = RelativeLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + controller.setEventListener(object : WebViewEventListener { + override fun onPageFinished(url: String?) { + mindboxLogD("onPageFinished: $url") + currentWebViewOrigin = resolveOrigin(url) ?: currentWebViewOrigin + webViewController?.evaluateJavaScript(JS_CHECK_BRIDGE, ::checkEvaluateJavaScript) + } + + override fun onShouldOverrideUrlLoading(url: String?, isForMainFrame: Boolean?): Boolean { + return handleShouldOverrideUrlLoading(url = url, isForMainFrame = isForMainFrame) + } + + override fun onError(error: WebViewError) { + mindboxLogE("WebView error: code=${error.code}, description=${error.description}, url=${error.url}") + if (error.isForMainFrame == true) { + inAppFailureTracker.sendFailureWithContext( + inAppId = wrapper.inAppType.inAppId, + failureReason = FailureReason.WEBVIEW_PRESENTATION_FAILED, + errorDescription = "WebView error: code=${error.code}, description=${error.description}, url=${error.url}" + ) + inAppController.close() } } + }) + return controller + } + + private fun handleShouldOverrideUrlLoading(url: String?, isForMainFrame: Boolean?): Boolean { + if (isForMainFrame != true) { + return false } - mindboxLogI("Show In-App ${wrapper.inAppType.inAppId} in holder ${this.hashCode()}") - inAppLayout.requestFocus() + if (shouldAllowLocalNavigation(url)) { + return false + } + val normalizedUrl: String = url?.trim().orEmpty() + sendNavigationInterceptedEvent(url = normalizedUrl) + return true } - override fun hide() { - // Clean up timeout when hiding - closeInappTimer?.cancel() - closeInappTimer = null - webView.get()?.let { - inAppLayout.removeView(it) + private fun sendNavigationInterceptedEvent(url: String) { + val controller: WebViewController = webViewController ?: return + val payload: String = gson.toJson(NavigationInterceptedPayload(url = url)) + val message: BridgeMessage.Request = BridgeMessage.createAction( + action = WebViewAction.NAVIGATION_INTERCEPTED, + payload = payload + ) + sendActionInternal(controller, message) { error -> + mindboxLogW("Failed to send navigationIntercepted event to WebView: $error") } - super.hide() } - override fun release() { - super.release() - // Clean up WebView resources - webView.get()?.apply { - stopLoading() - loadUrl("about:blank") - clearHistory() - removeAllViews() - destroy() + private fun shouldAllowLocalNavigation(url: String?): Boolean { + if (url.isNullOrBlank()) { + return true } - webView.clear() + val normalizedUrl: String = url.trim() + if (normalizedUrl.startsWith("#")) { + return true + } + if (normalizedUrl.startsWith("about:blank")) { + return true + } + val targetOrigin: String = resolveOrigin(normalizedUrl) ?: return false + val sourceOrigin: String = currentWebViewOrigin ?: return false + return targetOrigin == sourceOrigin } - private interface WebViewAction { - fun onInit() + private fun resolveOrigin(url: String?): String? { + if (url.isNullOrBlank()) { + return null + } + val parsedUri: Uri = runCatching { url.toUri() }.getOrNull() ?: return null + val scheme: String = parsedUri.scheme?.lowercase(Locale.US).orEmpty() + val host: String = parsedUri.host?.lowercase(Locale.US).orEmpty() + if (scheme.isBlank() || host.isBlank()) { + return null + } + val normalizedPort: String = if (parsedUri.port >= 0) ":${parsedUri.port}" else "" + return "$scheme://$host$normalizedPort" + } - fun onCompleted(data: String) + private fun sendBackAction(controller: WebViewController) { + val message: BridgeMessage.Request = BridgeMessage.createAction( + WebViewAction.BACK, + BridgeMessage.EMPTY_PAYLOAD + ) + sendActionInternal(controller, message) { error -> + mindboxLogW("Failed to send back action to WebView: $error") + inAppCallback.onInAppDismissed(wrapper.inAppType.inAppId) + inAppController.close() + } + } - fun onClose() + internal fun checkEvaluateJavaScript(response: String?): Boolean { + return when (response) { + JS_RETURN -> true + else -> { + inAppFailureTracker.sendFailureWithContext( + inAppId = wrapper.inAppType.inAppId, + failureReason = FailureReason.WEBVIEW_PRESENTATION_FAILED, + errorDescription = "evaluateJavaScript return unexpected response: $response" + ) + inAppController.close() + false + } + } + } - fun onHide() + private fun handleRequest(message: BridgeMessage.Request, controller: WebViewController, handlers: WebViewActionHandlers) { + if (handlers.hasSuspendHandler(message.action)) { + Mindbox.mindboxScope.launch { + val responsePayload: String = handlers.handleRequestSuspend(message) + .getOrElse { error -> + sendErrorResponse(message = message, error = error, controller = controller) + return@launch + } + sendSuccessResponse(message = message, responsePayload = responsePayload, controller = controller) + } + return + } + val responsePayload: String = handlers.handleRequest(message) + .getOrElse { error -> + sendErrorResponse(message = message, error = error, controller = controller) + return + } + sendSuccessResponse(message = message, responsePayload = responsePayload, controller = controller) + } - fun onLog(message: String) + private fun sendSuccessResponse( + message: BridgeMessage.Request, + responsePayload: String?, + controller: WebViewController, + ) { + val responseMessage: BridgeMessage.Response = BridgeMessage.createResponseAction(message, responsePayload) + sendActionInternal(controller, responseMessage) } - private fun WebView.handleWebViewAction(action: String, data: String, actions: WebViewAction) { - this.post { - mindboxLogI("handleWebViewAction: Action $action with $data") - when (action) { - "collapse", "close" -> actions.onClose() + private fun sendErrorResponse( + message: BridgeMessage.Request, + error: Throwable, + controller: WebViewController, + ) { + val json: String = runCatching { + val payload = ErrorPayload(error = requireNotNull(error.message)) + gson.toJson(payload) + }.getOrDefault(BridgeMessage.UNKNOWN_ERROR_PAYLOAD) + + val errorMessage: BridgeMessage.Error = BridgeMessage.createErrorAction(message, json) + mindboxLogE("WebView send error response for ${message.action} with payload ${errorMessage.payload}") + sendActionInternal(controller, errorMessage) + } - "init" -> actions.onInit() + private fun handleResponse(message: BridgeMessage.Response) { + val responseDeferred: CompletableDeferred? = pendingResponsesById.remove(message.id) + if (responseDeferred == null) { + mindboxLogW("No pending response for id $message.id") + return + } + if (!responseDeferred.isCompleted) { + responseDeferred.complete(message) + } + } - "hide" -> actions.onHide() + private fun handleError(message: BridgeMessage.Error) { + mindboxLogW("WebView error: ${message.payload}") + val responseDeferred: CompletableDeferred? = pendingResponsesById.remove(message.id) + responseDeferred?.cancel("WebView error: ${message.payload}") + inAppController.close() + } - "click" -> actions.onCompleted(data) + private fun cancelPendingResponses(reason: String) { + val error: CancellationException = CancellationException(reason) + pendingResponsesById.values.forEach { deferred -> + if (!deferred.isCompleted) { + deferred.cancel(error) + } + } + pendingResponsesById.clear() + } - "log" -> actions.onLog(data) + private fun renderLayer(layer: Layer.WebViewLayer) { + if (webViewController == null) { + val controller: WebViewController = createWebViewController(layer) + webViewController = controller + + Mindbox.mindboxScope.launch { + val configuration: Configuration = DbManager.listenConfigurations().first() + val handlers: WebViewActionHandlers = createWebViewActionHandlers(controller, layer, configuration) + + controller.setVisibility(false) + controller.setJsBridge(bridge = { json -> + mindboxLogI("SDK <- receive message $json") + val message = gson.fromJson(json).getOrNull() + if (!messageValidator.isValid(message)) { + return@setJsBridge + } + + controller.executeOnViewThread { + when (message) { + is BridgeMessage.Request -> handleRequest(message, controller, handlers) + is BridgeMessage.Response -> handleResponse(message) + is BridgeMessage.Error -> handleError(message) + else -> mindboxLogW("Unknown message type: $message") + } + } + }) + + controller.setUserAgentSuffix(configuration.getShortUserAgent()) + + layer.contentUrl?.let { contentUrl -> + runCatching { + gatewayManager.fetchWebViewContent(contentUrl) + }.onSuccess { response: String -> + currentWebViewOrigin = resolveOrigin(layer.baseUrl) + onContentPageLoaded( + content = WebViewHtmlContent( + baseUrl = layer.baseUrl ?: "", + html = response + ) + ) + }.onFailure { e -> + inAppFailureTracker.sendFailureWithContext( + inAppId = wrapper.inAppType.inAppId, + failureReason = FailureReason.WEBVIEW_LOAD_FAILED, + errorDescription = "Failed to fetch HTML content for In-App", + throwable = e + ) + inAppController.close() + } + } ?: run { + inAppFailureTracker.sendFailureWithContext( + inAppId = wrapper.inAppType.inAppId, + failureReason = FailureReason.WEBVIEW_LOAD_FAILED, + errorDescription = "WebView content URL is null" + ) + inAppController.close() + } } } + + webViewController?.let { controller -> + inAppFailureTracker.executeWithFailureTracking( + inAppId = wrapper.inAppType.inAppId, + failureReason = FailureReason.PRESENTATION_FAILED, + errorDescription = "Error when trying WebView layout", + ) { + val view: WebViewPlatformView = controller.view + if (view.parent !== inAppLayout) { + view.parent.safeAs()?.removeView(view) + inAppLayout.addView(view) + } + } + } ?: run { + inAppFailureTracker.sendFailureWithContext( + inAppId = wrapper.inAppType.inAppId, + failureReason = FailureReason.WEBVIEW_PRESENTATION_FAILED, + errorDescription = "WebView controller is null when trying show inapp" + ) + inAppController.close() + } } - internal class InAppWebClient(private val onCriticalError: () -> Unit) : WebViewClient() { - override fun onReceivedError( - view: WebView?, - request: WebResourceRequest?, - error: WebResourceError? - ) { - super.onReceivedError(view, request, error) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - val message = "WebView error: code=${error?.errorCode}, description=${error?.description}, url=${request?.url}" - mindboxLogE(message) - if (request?.isForMainFrame == true) { - onCriticalError() + private fun onContentPageLoaded(content: WebViewHtmlContent) { + webViewController?.let { controller -> + controller.executeOnViewThread { + controller.loadContent(content) + } + startTimer { + inAppFailureTracker.sendFailureWithContext( + inAppId = wrapper.inAppType.inAppId, + failureReason = FailureReason.WEBVIEW_LOAD_FAILED, + errorDescription = "WebView initialization timed out after ${Stopwatch.stop(TIMER)}." + ) + controller.executeOnViewThread { + inAppController.close() } } + } ?: run { + mindboxLogW("WebView controller is null when loading content, skipping") } + } - @Suppress("DEPRECATION") - @Deprecated("Deprecated in Java") - override fun onReceivedError( - view: WebView?, - errorCode: Int, - description: String?, - failingUrl: String? - ) { - super.onReceivedError(view, errorCode, description, failingUrl) - val message = "WebView error (legacy): code=$errorCode, description=$description, url=$failingUrl" - mindboxLogE(message) - // In the old API, we can't be sure if it's the main frame, - // but any error is likely critical. The timeout will still act as a fallback. - if (failingUrl == view?.originalUrl) { - onCriticalError() + private fun stopTimer() { + closeInappTimer?.let { timer -> + mindboxLogI("WebView initialization completed " + Stopwatch.stop(TIMER)) + timer.cancel() + } + closeInappTimer = null + } + + private fun startTimer(onTimeOut: () -> Unit) { + Stopwatch.start(TIMER) + closeInappTimer = timer( + initialDelay = INIT_TIMEOUT_MS, + period = INIT_TIMEOUT_MS, + action = { onTimeOut() } + ) + } + + override fun show(currentRoot: MindboxView) { + currentMindboxView = currentRoot + super.show(currentRoot) + mindboxLogI("Try to show in-app with id ${wrapper.inAppType.inAppId}") + wrapper.inAppType.layers.forEach { layer -> + when (layer) { + is Layer.WebViewLayer -> renderLayer(layer) + else -> mindboxLogW("Layer is not supported") } } + mindboxLogI("Show In-App ${wrapper.inAppType.inAppId} in holder ${this.hashCode()}") + } - override fun shouldOverrideUrlLoading( - view: WebView?, - request: WebResourceRequest? - ): Boolean { - mindboxLogD("shouldOverrideUrlLoading: ${request?.url}") - return super.shouldOverrideUrlLoading(view, request) + override fun reattach(currentRoot: MindboxView) { + currentMindboxView = currentRoot + super.reattach(currentRoot) + wrapper.inAppType.layers.forEach { layer -> + when (layer) { + is Layer.WebViewLayer -> renderLayer(layer) + else -> mindboxLogW("Layer is not supported") + } } + inAppLayout.requestFocus() + webViewController?.let { controller -> bindWebViewBackAction(currentRoot, controller) } + } + + override fun canReuseOnRestore(inAppId: String): Boolean = wrapper.inAppType.inAppId == inAppId - override fun onPageFinished(view: WebView?, url: String?) { - mindboxLogD("onPageFinished: $url") - super.onPageFinished(view, url) + override fun onStop() { + // do nothing + } + + override fun onClose() { + hapticFeedbackExecutor.cancel() + motionService?.stopMonitoring() + stopTimer() + cancelPendingResponses("WebView In-App is closed") + webViewController?.let { controller -> + val view: WebViewPlatformView = controller.view + view.parent.safeAs()?.removeView(view) + controller.destroy() } + currentWebViewOrigin = null + webViewController?.destroy() + webViewController = null + currentMindboxView = null + super.onClose() + } - override fun shouldOverrideKeyEvent(view: WebView?, event: KeyEvent?): Boolean { - mindboxLogD("shouldOverrideKeyEvent: $event") - return super.shouldOverrideKeyEvent(view, event) + private fun getOrCreateMotionService(): MotionServiceProtocol = + motionService ?: MotionService( + context = appContext, + lifecycle = ProcessLifecycleOwner.get().lifecycle, + timeProvider = timeProvider, + ).also { service -> + service.onGestureDetected = { gesture, data -> + sendMotionEvent(gesture = gesture, data = data) + } + motionService = service } + + private data class NavigationInterceptedPayload( + val url: String + ) + + private data class ErrorPayload( + val error: String + ) + + private data class MotionStartPayload( + @SerializedName("success") + val success: Boolean = true, + @SerializedName("unavailable") + val unavailable: List? = null, + ) + + private data class SettingsOpenRequest( + @SerializedName("target") + val target: String, + @SerializedName("channelId") + val channelId: String? + ) + + private enum class SettingsOpenTargetType { + NOTIFICATIONS, + APPLICATION } } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewLinkRouter.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewLinkRouter.kt new file mode 100644 index 000000000..26cbb475b --- /dev/null +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewLinkRouter.kt @@ -0,0 +1,139 @@ +package cloud.mindbox.mobile_sdk.inapp.presentation.view + +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.core.net.toUri +import cloud.mindbox.mobile_sdk.logger.mindboxLogW +import com.google.gson.JsonParser + +internal interface WebViewLinkRouter { + fun executeOpenLink(request: String?): Result +} + +internal class MindboxWebViewLinkRouter( + private val context: Context, +) : WebViewLinkRouter { + + companion object { + private const val SCHEME_HTTP = "http" + private const val SCHEME_HTTPS = "https" + private const val SCHEME_INTENT = "intent" + private const val SCHEME_TEL = "tel" + private const val SCHEME_MAILTO = "mailto" + private const val SCHEME_SMS = "sms" + private const val KEY_URL = "url" + private val BLOCKED_SCHEMES: Set = setOf("javascript", "file", "data", "blob") + private const val ERROR_MISSING_URL = "Invalid payload: missing or empty 'url' field" + } + + override fun executeOpenLink(request: String?): Result { + return runCatching { + val url: String = extractTargetUrl(request) + val parsedUri = parseUrl(url) + routeByScheme( + parsedUri = parsedUri, + targetUrl = url, + ) + } + } + + private fun extractTargetUrl(request: String?): String { + if (request.isNullOrBlank()) { + throw IllegalStateException(ERROR_MISSING_URL) + } + val parsedJsonElement = runCatching { JsonParser.parseString(request) }.getOrNull() + ?: throw IllegalStateException(ERROR_MISSING_URL) + if (!parsedJsonElement.isJsonObject) { + throw IllegalStateException(ERROR_MISSING_URL) + } + val url: String = parsedJsonElement.asJsonObject.get(KEY_URL)?.asString?.trim().orEmpty() + if (url.isBlank()) { + throw IllegalStateException(ERROR_MISSING_URL) + } + return url + } + + private fun parseUrl(url: String): Uri { + val parsedUri: Uri = url.toUri() + val scheme: String = parsedUri.scheme?.lowercase().orEmpty() + if (scheme.isBlank()) { + throw IllegalStateException("Invalid URL: '$url' could not be parsed") + } + if (scheme in BLOCKED_SCHEMES) { + throw IllegalStateException("Blocked URL scheme: '$scheme'") + } + return parsedUri + } + + private fun routeByScheme( + parsedUri: Uri, + targetUrl: String, + ): String { + val scheme = parsedUri.scheme + requireNotNull(scheme) { "Url scheme must be not null" } + return when (scheme.lowercase()) { + SCHEME_INTENT -> openIntentUri(targetUrl) + SCHEME_TEL -> openDialLink(parsedUri, targetUrl) + SCHEME_SMS, SCHEME_MAILTO -> openSendToLink(parsedUri, targetUrl) + SCHEME_HTTP, SCHEME_HTTPS -> openUriWithViewIntent(parsedUri, targetUrl) + else -> openUriWithViewIntent(parsedUri, targetUrl) + } + } + + private fun openIntentUri(rawIntentUri: String): String { + val parsedIntent: Intent = runCatching { Intent.parseUri(rawIntentUri, Intent.URI_INTENT_SCHEME) } + .getOrElse { + mindboxLogW("Intent URI parse failed: $rawIntentUri") + throw IllegalStateException("Invalid URL: '$rawIntentUri' could not be parsed") + } + if (parsedIntent.action.isNullOrBlank()) { + parsedIntent.action = Intent.ACTION_VIEW + } + parsedIntent.selector = null + parsedIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + return startIntent(parsedIntent, rawIntentUri) + } + + private fun openDialLink(uri: Uri, rawUrl: String): String { + val dialIntent: Intent = Intent(Intent.ACTION_DIAL, uri).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + return startIntent(dialIntent, rawUrl) + } + + private fun openSendToLink(uri: Uri, rawUrl: String): String { + val smsIntent: Intent = Intent(Intent.ACTION_SENDTO, uri).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + return startIntent(smsIntent, rawUrl) + } + + private fun openUriWithViewIntent(uri: Uri, rawUrl: String): String { + val intent: Intent = Intent(Intent.ACTION_VIEW, uri).apply { + addCategory(Intent.CATEGORY_BROWSABLE) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + return startIntent(intent, rawUrl) + } + + private fun startIntent(intent: Intent, rawUrl: String): String { + return try { + context.startActivity(intent) + rawUrl + } catch (error: ActivityNotFoundException) { + mindboxLogW("Activity not found for URI: $rawUrl") + throw IllegalStateException( + "ActivityNotFoundException: ${error.message ?: "No activity found to handle URL"}" + ) + } catch (error: SecurityException) { + mindboxLogW("Security exception for URI: $rawUrl") + throw IllegalStateException( + "SecurityException: ${error.message ?: "Cannot open URL"}" + ) + } catch (error: Throwable) { + throw IllegalStateException(error.message ?: "Navigation failed: unable to open URL") + } + } +} diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewLocalStateStore.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewLocalStateStore.kt new file mode 100644 index 000000000..cc9f179db --- /dev/null +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewLocalStateStore.kt @@ -0,0 +1,72 @@ +package cloud.mindbox.mobile_sdk.inapp.presentation.view + +import android.content.Context +import android.content.SharedPreferences +import androidx.core.content.edit +import cloud.mindbox.mobile_sdk.repository.MindboxPreferences +import org.json.JSONArray +import org.json.JSONObject + +internal class WebViewLocalStateStore( + context: Context +) { + companion object { + private const val LOCAL_STATE_FILE_NAME: String = "mindbox_webview_local_state" + private const val FIELD_DATA: String = "data" + private const val FIELD_VERSION: String = "version" + } + + private val localStatePreferences: SharedPreferences = + context.getSharedPreferences(LOCAL_STATE_FILE_NAME, Context.MODE_PRIVATE) + + fun getState(payload: String): String { + val requestedKeys: JSONArray = JSONObject(payload).optJSONArray(FIELD_DATA) ?: JSONArray() + val keys: List = (0.. requestedKeys.getString(i) } + val savedData: Map = localStatePreferences.all.mapValues { it.value?.toString() } + + return buildResponse( + data = savedData + .takeIf { keys.isEmpty() } + ?: keys.associateWith { key -> savedData[key] } + ) + } + + fun setState(payload: String): String { + val jsonData: JSONObject = JSONObject(payload).getJSONObject(FIELD_DATA) + val dataToSet = jsonData.toMap() + + localStatePreferences.edit { + dataToSet.forEach { (key, value) -> + value?.let { putString(key, value) } + ?: remove(key) + } + } + + return buildResponse(data = dataToSet) + } + + fun initState(payload: String): String { + val version: Int = JSONObject(payload).getInt(FIELD_VERSION) + require(version > 0) { "Version must be greater than 0" } + + MindboxPreferences.localStateVersion = version + + return setState(payload = payload) + } + + private fun JSONObject.toMap(): Map { + return buildMap(capacity = this.length()) { + keys().forEach { key -> + val value: Any? = opt(key) + put(key, if (value == null || value == JSONObject.NULL) null else value.toString()) + } + } + } + + private fun buildResponse(data: Map): String { + val responseObject: JSONObject = JSONObject() + .put(FIELD_DATA, JSONObject(data)) + .put(FIELD_VERSION, MindboxPreferences.localStateVersion) + return responseObject.toString() + } +} diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewOperationExecutor.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewOperationExecutor.kt new file mode 100644 index 000000000..80812e9f1 --- /dev/null +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewOperationExecutor.kt @@ -0,0 +1,65 @@ +package cloud.mindbox.mobile_sdk.inapp.presentation.view + +import android.app.Application +import cloud.mindbox.mobile_sdk.managers.MindboxEventManager +import cloud.mindbox.mobile_sdk.models.MindboxError +import com.google.gson.JsonObject +import com.google.gson.JsonParser +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +internal interface WebViewOperationExecutor { + + fun executeAsyncOperation(context: Application, payload: String?) + + suspend fun executeSyncOperation(payload: String?): String +} + +internal class MindboxWebViewOperationExecutor : WebViewOperationExecutor { + + companion object { + private const val OPERATION_FIELD = "operation" + private const val BODY_FIELD = "body" + } + + override fun executeAsyncOperation(context: Application, payload: String?) { + val (operation, body) = parseOperationRequest(payload) + MindboxEventManager.asyncOperation( + context = context, + name = operation, + body = body, + ) + } + + override suspend fun executeSyncOperation(payload: String?): String { + val (operation, body) = parseOperationRequest(payload) + return suspendCancellableCoroutine { continuation -> + MindboxEventManager.syncOperation( + name = operation, + bodyJson = body, + onSuccess = { responseBody: String -> + if (continuation.isActive) { + continuation.resume(responseBody) + } + }, + onError = { error: MindboxError -> + if (continuation.isActive) { + continuation.resumeWithException( + IllegalStateException(error.toJson()) + ) + } + }, + ) + } + } + + private fun parseOperationRequest(payload: String?): Pair { + val jsonObject: JsonObject = JsonParser.parseString(payload).asJsonObject + val operation: String = jsonObject.getAsJsonPrimitive(OPERATION_FIELD)?.asString + ?: throw IllegalArgumentException("Operation is not provided") + val body: String = jsonObject.getAsJsonObject(BODY_FIELD)?.toString() + ?: throw IllegalArgumentException("Body is not provided") + return operation to body + } +} diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewPermissionRequester.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewPermissionRequester.kt new file mode 100644 index 000000000..9a5f49d06 --- /dev/null +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewPermissionRequester.kt @@ -0,0 +1,160 @@ +package cloud.mindbox.mobile_sdk.inapp.presentation.view + +import android.Manifest +import android.annotation.SuppressLint +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.os.Build +import cloud.mindbox.mobile_sdk.Mindbox +import cloud.mindbox.mobile_sdk.inapp.data.managers.PermissionManagerImpl +import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.PermissionManager +import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.PermissionStatus +import cloud.mindbox.mobile_sdk.inapp.presentation.actions.PushActivationActivity +import cloud.mindbox.mobile_sdk.inapp.presentation.actions.RuntimePermissionRequestBridge +import com.google.gson.annotations.SerializedName +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +internal const val PERMISSION_PAYLOAD_TYPE_FIELD_NAME = "type" + +internal interface WebViewPermissionRequester { + suspend fun requestPermission(activity: Activity, permissionType: PermissionType): PermissionActionResponse +} + +internal enum class PermissionType(val value: String) { + PUSH_NOTIFICATIONS("pushNotifications") +} + +internal data class PermissionActionResponse( + @SerializedName("result") + val result: PermissionRequestStatus, + @SerializedName("dialogShown") + val dialogShown: Boolean, + @SerializedName("details") + val details: PermissionActionDetails, +) + +internal data class PermissionActionDetails( + @SerializedName("required") + val required: Boolean, + @SerializedName("shouldShowRequestPermissionRationale") + val shouldShowRequestPermissionRationale: Boolean? = null, +) + +internal enum class PermissionRequestStatus(val value: String) { + @SerializedName("granted") + GRANTED("granted"), + + @SerializedName("denied") + DENIED("denied") +} + +@SuppressLint("InlinedApi") +internal class WebViewPermissionRequesterImpl( + private val context: Context, + private val pushPermissionLauncher: PushPermissionLauncher = PushPermissionLauncherImpl(), + private val permissionManager: PermissionManager = PermissionManagerImpl(context), + private val sdkIntProvider: () -> Int = { Build.VERSION.SDK_INT }, +) : WebViewPermissionRequester { + + override suspend fun requestPermission( + activity: Activity, + permissionType: PermissionType + ): PermissionActionResponse { + val currentStatus: PermissionStatus = getPermissionStatus(permissionType) + if (isGrantedStatus(currentStatus)) { + return createPermissionActionResponse( + result = PermissionRequestStatus.GRANTED, + dialogShown = false + ) + } + if (!isPermissionRequired()) { + return createPermissionActionResponse( + result = PermissionRequestStatus.DENIED, + dialogShown = false + ) + } + + val permissionResult: PushPermissionRequestResult = pushPermissionLauncher.requestPermission(activity = activity) + return createPermissionActionResponse( + result = permissionResult.status, + dialogShown = permissionResult.dialogShown, + shouldShowRequestPermissionRationale = permissionResult.shouldShowRequestPermissionRationale + ) + } + + private fun getPermissionStatus(permissionType: PermissionType): PermissionStatus { + return when (permissionType) { + PermissionType.PUSH_NOTIFICATIONS -> permissionManager.getNotificationPermissionStatus() + } + } + + private fun isGrantedStatus(permissionStatus: PermissionStatus): Boolean { + return permissionStatus == PermissionStatus.GRANTED || permissionStatus == PermissionStatus.LIMITED + } + + private fun isPermissionRequired(): Boolean = sdkIntProvider() >= Build.VERSION_CODES.TIRAMISU + + private fun createPermissionActionResponse( + result: PermissionRequestStatus, + dialogShown: Boolean, + shouldShowRequestPermissionRationale: Boolean? = null + ): PermissionActionResponse { + val isPermissionRequired: Boolean = isPermissionRequired() + return PermissionActionResponse( + result = result, + dialogShown = dialogShown, + details = PermissionActionDetails( + required = isPermissionRequired, + shouldShowRequestPermissionRationale = shouldShowRequestPermissionRationale + ) + ) + } +} + +internal interface PushPermissionLauncher { + suspend fun requestPermission(activity: Activity): PushPermissionRequestResult +} + +internal data class PushPermissionRequestResult( + val status: PermissionRequestStatus, + val shouldShowRequestPermissionRationale: Boolean, + val dialogShown: Boolean, +) + +@SuppressLint("InlinedApi") +internal class PushPermissionLauncherImpl( + private val sdkIntProvider: () -> Int = { Build.VERSION.SDK_INT } +) : PushPermissionLauncher { + private val notificationPermission: String = Manifest.permission.POST_NOTIFICATIONS + + override suspend fun requestPermission(activity: Activity): PushPermissionRequestResult { + if (sdkIntProvider() < Build.VERSION_CODES.TIRAMISU) { + return PushPermissionRequestResult( + status = PermissionRequestStatus.DENIED, + shouldShowRequestPermissionRationale = false, + dialogShown = false + ) + } + val requestId: String = Mindbox.generateRandomUuid() + val deferredResult = RuntimePermissionRequestBridge.register(requestId) + withContext(Dispatchers.Main.immediate) { + activity.startActivity( + Intent(activity, PushActivationActivity::class.java).apply { + putExtra(PushActivationActivity.EXTRA_REQUEST_ID, requestId) + putExtra(PushActivationActivity.EXTRA_ROUTE_TO_SETTINGS, false) + } + ) + } + val (isGranted, dialogShown) = deferredResult.await() + val shouldShowRationale: Boolean = withContext(Dispatchers.Main.immediate) { + activity.shouldShowRequestPermissionRationale(notificationPermission) + } + return PushPermissionRequestResult( + status = if (isGranted) PermissionRequestStatus.GRANTED else PermissionRequestStatus.DENIED, + shouldShowRequestPermissionRationale = shouldShowRationale, + dialogShown = dialogShown + ) + } +} diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/motion/MotionService.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/motion/MotionService.kt new file mode 100644 index 000000000..757520fbf --- /dev/null +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/motion/MotionService.kt @@ -0,0 +1,271 @@ +package cloud.mindbox.mobile_sdk.inapp.presentation.view.motion + +import android.content.Context +import android.hardware.Sensor +import android.hardware.SensorEvent +import android.hardware.SensorEventListener +import android.hardware.SensorManager +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import cloud.mindbox.mobile_sdk.logger.mindboxLogI +import cloud.mindbox.mobile_sdk.models.Milliseconds +import cloud.mindbox.mobile_sdk.models.Timestamp +import cloud.mindbox.mobile_sdk.utils.TimeProvider +import cloud.mindbox.mobile_sdk.utils.loggingRunCatching +import kotlin.math.abs +import kotlin.math.hypot + +internal enum class MotionGesture(val value: String) { + SHAKE("shake"), + FLIP("flip"), +} + +internal enum class DevicePosition(val value: String) { + FACE_UP("faceUp"), + FACE_DOWN("faceDown"), + PORTRAIT("portrait"), + PORTRAIT_UPSIDE_DOWN("portraitUpsideDown"), + LANDSCAPE_LEFT("landscapeLeft"), + LANDSCAPE_RIGHT("landscapeRight"), +} + +internal data class MotionVector(val x: Float, val y: Float, val z: Float) { + companion object { + val ZERO: MotionVector = MotionVector(0f, 0f, 0f) + } + + operator fun minus(other: MotionVector): MotionVector = MotionVector(x - other.x, y - other.y, z - other.z) + + fun magnitude(): Float = hypot(hypot(x, y), z) +} + +internal data class MotionStartResult( + val started: Set, + val unavailable: Set, +) { + val allUnavailable: Boolean get() = started.isEmpty() && unavailable.isNotEmpty() +} + +internal interface MotionServiceProtocol { + var onGestureDetected: ((gesture: MotionGesture, data: Map) -> Unit)? + + fun startMonitoring(gestures: Set): MotionStartResult + + fun stopMonitoring() +} + +internal class MotionService( + private val context: Context, + private val lifecycle: Lifecycle, + private val timeProvider: TimeProvider, +) : MotionServiceProtocol { + + private companion object { + const val SMOOTHING_FACTOR = 0.7f + val SHAKE_COOLDOWN = Milliseconds(800L) + const val TABLET_MIN_WIDTH_DP = 600 + const val PHONE_THRESHOLD_G = 3.0f + const val TABLET_THRESHOLD_G = 1.5f + const val FLIP_ENTER_THRESHOLD_G = 0.8f + const val FLIP_EXIT_THRESHOLD_G = 0.6f + } + + override var onGestureDetected: ((gesture: MotionGesture, data: Map) -> Unit)? = null + + private val sensorManager: SensorManager? = + context.getSystemService(Context.SENSOR_SERVICE) as? SensorManager + + private val shakeAccelerationThreshold: Float by lazy { + val isTablet = context.resources.configuration.smallestScreenWidthDp >= TABLET_MIN_WIDTH_DP + val thresholdG = if (isTablet) TABLET_THRESHOLD_G else PHONE_THRESHOLD_G + thresholdG * SensorManager.GRAVITY_EARTH + } + + private var activeGestures: Set = emptySet() + private var suspendedGestures: Set? = null + + private var lastShakeVector: MotionVector = MotionVector.ZERO + private var accumulateShake = 0f + private var lastShakeTimestamp: Timestamp = Timestamp.ZERO + + private var currentFlipPosition: DevicePosition? = null + + private val shakeListener = object : SensorEventListener { + override fun onSensorChanged(event: SensorEvent) { + processShake(MotionVector(event.values[0], event.values[1], event.values[2])) + } + + override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) = Unit + } + + private val flipListener = object : SensorEventListener { + override fun onSensorChanged(event: SensorEvent) { + processFlip(MotionVector(-event.values[0], -event.values[1], -event.values[2])) + } + + override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) = Unit + } + + private val lifecycleObserver = object : DefaultLifecycleObserver { + override fun onStop(owner: LifecycleOwner) = suspend() + + override fun onStart(owner: LifecycleOwner) = resume() + } + + override fun startMonitoring(gestures: Set): MotionStartResult { + if (activeGestures.isNotEmpty()) stopMonitoring() + val unavailable = buildSet { + if (gestures.contains(MotionGesture.SHAKE) && !isShakeAvailable()) { + add(MotionGesture.SHAKE) + } + if (gestures.contains(MotionGesture.FLIP) && !isFlipAvailable()) { + add(MotionGesture.FLIP) + } + } + + activeGestures = gestures - unavailable + val result = MotionStartResult(started = activeGestures, unavailable = unavailable) + if (activeGestures.isEmpty()) return result + addLifecycleObserver() + startSensors() + + mindboxLogI("Motion: monitoring started for ${activeGestures.map { it.value }}") + if (unavailable.isNotEmpty()) { + mindboxLogI("Motion: unavailable gestures: ${unavailable.map { it.value }}") + } + return result + } + + override fun stopMonitoring() { + if (activeGestures.isEmpty() && suspendedGestures == null) return + removeLifecycleObserver() + stopSensors() + activeGestures = emptySet() + suspendedGestures = null + mindboxLogI("Motion: monitoring stopped") + } + + private fun addLifecycleObserver() { + lifecycle.addObserver(lifecycleObserver) + } + + private fun removeLifecycleObserver() { + lifecycle.removeObserver(lifecycleObserver) + } + + internal fun suspend() { + if (activeGestures.isEmpty()) return + suspendedGestures = activeGestures + stopSensors() + mindboxLogI("Motion: suspended (app in background)") + } + + internal fun resume() { + val gestures = suspendedGestures ?: return + suspendedGestures = null + activeGestures = gestures + startSensors() + mindboxLogI("Motion: resumed (app in foreground)") + } + + private fun startSensors() { + if (activeGestures.contains(MotionGesture.SHAKE)) { + sensorManager?.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)?.let { sensor -> + sensorManager.registerListener(shakeListener, sensor, SensorManager.SENSOR_DELAY_NORMAL) + } + } + if (activeGestures.contains(MotionGesture.FLIP)) { + sensorManager?.getDefaultSensor(Sensor.TYPE_GRAVITY)?.let { sensor -> + sensorManager.registerListener(flipListener, sensor, SensorManager.SENSOR_DELAY_NORMAL) + } + } + } + + private fun stopSensors() { + sensorManager?.unregisterListener(shakeListener) + sensorManager?.unregisterListener(flipListener) + resetShakeState() + currentFlipPosition = null + } + + private fun resetShakeState() { + lastShakeVector = MotionVector.ZERO + accumulateShake = 0f + lastShakeTimestamp = Timestamp.ZERO + } + + private fun isShakeAvailable(): Boolean = + sensorManager?.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) != null + + private fun isFlipAvailable(): Boolean = + sensorManager?.getDefaultSensor(Sensor.TYPE_GRAVITY) != null + + internal fun processShake(vector: MotionVector) { + val delta = (vector - lastShakeVector).magnitude() + accumulateShake = accumulateShake * SMOOTHING_FACTOR + delta + val now: Timestamp = timeProvider.currentTimestamp() + val elapsed: Milliseconds = timeProvider.elapsedSince(lastShakeTimestamp) + if (accumulateShake > shakeAccelerationThreshold && elapsed.interval > SHAKE_COOLDOWN.interval) { + accumulateShake = 0f + lastShakeTimestamp = now + loggingRunCatching { onGestureDetected?.invoke(MotionGesture.SHAKE, emptyMap()) } + } + lastShakeVector = vector + } + + private fun processFlip(vector: MotionVector) { + val newPosition = resolvePosition(vector = vector, current = currentFlipPosition) + if (newPosition == null || newPosition == currentFlipPosition) return + + val from = currentFlipPosition + currentFlipPosition = newPosition + + if (from == null) return + + loggingRunCatching { + onGestureDetected?.invoke( + MotionGesture.FLIP, + mapOf("from" to from.value, "to" to newPosition.value), + ) + } + } + + internal fun resolvePosition( + vector: MotionVector, + current: DevicePosition?, + ): DevicePosition? { + data class Axis( + val value: Float, + val negative: DevicePosition, + val positive: DevicePosition, + ) + + val axes = listOf( + Axis(vector.z, DevicePosition.FACE_UP, DevicePosition.FACE_DOWN), + Axis(vector.y, DevicePosition.PORTRAIT, DevicePosition.PORTRAIT_UPSIDE_DOWN), + Axis(vector.x, DevicePosition.LANDSCAPE_LEFT, DevicePosition.LANDSCAPE_RIGHT), + ) + + if (current != null) { + axes.forEach { axis -> + val position = if (axis.value > 0f) axis.positive else axis.negative + if (position == current && abs(axis.value) > FLIP_EXIT_THRESHOLD_G * SensorManager.GRAVITY_EARTH) { + return current + } + } + } + + var dominantPosition: DevicePosition? = null + var maxMagnitude = FLIP_ENTER_THRESHOLD_G * SensorManager.GRAVITY_EARTH + + for (axis in axes) { + val magnitude = abs(axis.value) + if (magnitude > maxMagnitude) { + maxMagnitude = magnitude + dominantPosition = if (axis.value > 0f) axis.positive else axis.negative + } + } + return dominantPosition + } +} 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 ba5b86d2d..7a856bef5 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 @@ -6,6 +6,7 @@ import cloud.mindbox.mobile_sdk.fromJsonTyped import cloud.mindbox.mobile_sdk.inapp.data.dto.GeoTargetingDto import cloud.mindbox.mobile_sdk.inapp.domain.models.* import cloud.mindbox.mobile_sdk.logger.MindboxLoggerImpl +import cloud.mindbox.mobile_sdk.logger.mindboxLogE import cloud.mindbox.mobile_sdk.models.* import cloud.mindbox.mobile_sdk.models.operation.OperationResponseBaseInternal import cloud.mindbox.mobile_sdk.models.operation.request.LogResponseDto @@ -20,6 +21,7 @@ import com.android.volley.DefaultRetryPolicy.DEFAULT_BACKOFF_MULT import com.android.volley.ParseError import com.android.volley.Request import com.android.volley.VolleyError +import com.android.volley.toolbox.StringRequest import com.google.gson.Gson import kotlinx.coroutines.* import org.json.JSONException @@ -445,6 +447,25 @@ internal class GatewayManager(private val mindboxServiceGenerator: MindboxServic } } + suspend fun fetchWebViewContent(contentUrl: String): String { + return suspendCoroutine { continuation -> + try { + val request: StringRequest = StringRequest( + Request.Method.GET, + contentUrl, + { response -> continuation.resume(response) }, + { error -> continuation.resumeWithException(error) } + ).apply { + setShouldCache(false) + } + mindboxServiceGenerator.addToRequestQueue(request) + } catch (e: Exception) { + mindboxLogE("Failed to fetch WebView content", e) + continuation.resumeWithException(e) + } + } + } + private inline fun Continuation.resumeFromJson(json: String) { loggingRunCatching(null) { gson.fromJsonTyped(json) diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/managers/MindboxEventManager.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/managers/MindboxEventManager.kt index 92ea666b4..7f2120877 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/managers/MindboxEventManager.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/managers/MindboxEventManager.kt @@ -28,6 +28,7 @@ internal object MindboxEventManager { const val IN_APP_OPERATION_VIEW_TYPE = "Inapp.Show" const val IN_APP_OPERATION_CLICK_TYPE = "Inapp.Click" const val IN_APP_OPERATION_TARGETING_TYPE = "Inapp.Targeting" + const val IN_APP_OPERATION_SHOW_FAILURE_TYPE = "Inapp.ShowFailure" private val gson = Gson() @@ -84,6 +85,10 @@ internal object MindboxEventManager { asyncOperation(context, IN_APP_OPERATION_TARGETING_TYPE, body) } + fun inAppShowFailure(context: Context, body: String) { + asyncOperation(context, IN_APP_OPERATION_SHOW_FAILURE_TYPE, body) + } + fun pushClicked( context: Context, clickData: TrackClickData, @@ -104,7 +109,7 @@ internal object MindboxEventManager { ) } - fun asyncOperation(context: Context, name: String, body: String) = + fun asyncOperation(context: Context, name: String, body: String): Unit = asyncOperation( context, Event( diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/models/Timestamp.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/models/Timestamp.kt index 6f87e3186..65deadc5a 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/models/Timestamp.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/models/Timestamp.kt @@ -1,5 +1,9 @@ package cloud.mindbox.mobile_sdk.models +import cloud.mindbox.mobile_sdk.convertToString +import cloud.mindbox.mobile_sdk.convertToZonedDateTimeAtUTC +import org.threeten.bp.Instant + /** * Represents a specific point in time as milliseconds since the Unix epoch (January 1, 1970, 00:00:00 UTC) */ @@ -8,6 +12,16 @@ internal value class Timestamp(val ms: Long) { operator fun plus(milliseconds: Long): Timestamp = Timestamp(ms + milliseconds) operator fun minus(timestamp: Timestamp): Timestamp = Timestamp(ms - timestamp.ms) + + companion object { + val ZERO: Timestamp = Timestamp(0L) + } } internal fun Long.toTimestamp(): Timestamp = Timestamp(this) + +internal fun Timestamp.convertToIso8601String(): String { + return Instant.ofEpochMilli(ms) + .convertToZonedDateTimeAtUTC() + .convertToString() +} diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/models/operation/request/InAppHandleRequest.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/models/operation/request/InAppHandleRequest.kt index 1d3a9b57f..b8256c07a 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/models/operation/request/InAppHandleRequest.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/models/operation/request/InAppHandleRequest.kt @@ -3,6 +3,15 @@ package cloud.mindbox.mobile_sdk.models.operation.request import com.google.gson.annotations.SerializedName internal data class InAppHandleRequest( - @SerializedName("inappid") + @SerializedName("inappId") val inAppId: String ) + +internal data class InAppShowRequest( + @SerializedName("inappId") + val inAppId: String, + @SerializedName("timeToDisplay") + val timeToDisplay: String, + @SerializedName("tags") + val tags: Map? +) diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/models/operation/request/InAppShowFailure.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/models/operation/request/InAppShowFailure.kt new file mode 100644 index 000000000..c3a0b4fe4 --- /dev/null +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/models/operation/request/InAppShowFailure.kt @@ -0,0 +1,40 @@ +package cloud.mindbox.mobile_sdk.models.operation.request + +import com.google.gson.annotations.SerializedName + +internal data class InAppShowFailure( + @SerializedName("inappId") + val inAppId: String, + @SerializedName("failureReason") + val failureReason: FailureReason, + @SerializedName("errorDetails") + val errorDetails: String?, + @SerializedName("dateTimeUtc") + val dateTimeUtc: String +) + +internal enum class FailureReason(val value: String) { + @SerializedName("image_download_failed") + IMAGE_DOWNLOAD_FAILED("image_download_failed"), + + @SerializedName("presentation_failed") + PRESENTATION_FAILED("presentation_failed"), + + @SerializedName("geo_request_failed") + GEO_TARGETING_FAILED("geo_request_failed"), + + @SerializedName("customer_segmentation_request_failed") + CUSTOMER_SEGMENT_REQUEST_FAILED("customer_segmentation_request_failed"), + + @SerializedName("product_segmentation_request_failed") + PRODUCT_SEGMENT_REQUEST_FAILED("product_segmentation_request_failed"), + + @SerializedName("webview_load_failed") + WEBVIEW_LOAD_FAILED("webview_load_failed"), + + @SerializedName("webview_presentation_failed") + WEBVIEW_PRESENTATION_FAILED("webview_presentation_failed"), + + @SerializedName("unknown_error") + UNKNOWN_ERROR("unknown_error") +} 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 5125ed618..f42155dfd 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 @@ -1,6 +1,7 @@ package cloud.mindbox.mobile_sdk.models.operation.response import cloud.mindbox.mobile_sdk.inapp.data.dto.PayloadDto +import cloud.mindbox.mobile_sdk.inapp.data.dto.deserializers.FeatureTogglesDtoBlankDeserializer import cloud.mindbox.mobile_sdk.inapp.data.dto.deserializers.InAppIsPriorityDeserializer import cloud.mindbox.mobile_sdk.models.Milliseconds import cloud.mindbox.mobile_sdk.models.TimeSpan @@ -11,6 +12,7 @@ import cloud.mindbox.mobile_sdk.inapp.data.dto.deserializers.SlidingExpirationDt import cloud.mindbox.mobile_sdk.inapp.data.dto.deserializers.InappSettingsDtoBlankDeserializer import com.google.gson.annotations.JsonAdapter import cloud.mindbox.mobile_sdk.inapp.data.dto.deserializers.InAppDelayTimeDeserializer +import cloud.mindbox.mobile_sdk.inapp.data.dto.deserializers.InAppTagsDeserializer internal data class InAppConfigResponse( @SerializedName("inapps") @@ -31,7 +33,9 @@ internal data class SettingsDtoBlank( @SerializedName("slidingExpiration") val slidingExpiration: SlidingExpirationDtoBlank?, @SerializedName("inapp") - val inappSettings: InappSettingsDtoBlank? + val inappSettings: InappSettingsDtoBlank?, + @SerializedName("featureToggles") + val featureToggles: FeatureTogglesDtoBlank? ) { internal data class OperationDtoBlank( @SerializedName("systemName") @@ -60,6 +64,11 @@ internal data class SettingsDtoBlank( @SerializedName(InappSettingsDtoBlankDeserializer.MIN_INTERVAL_BETWEEN_SHOWS) val minIntervalBetweenShows: TimeSpan?, ) + + @JsonAdapter(FeatureTogglesDtoBlankDeserializer::class) + internal data class FeatureTogglesDtoBlank( + val toggles: Map + ) } internal data class SettingsDto( @@ -70,7 +79,9 @@ internal data class SettingsDto( @SerializedName("slidingExpiration") val slidingExpiration: SlidingExpirationDto?, @SerializedName("inapp") - val inapp: InappSettingsDto? + val inapp: InappSettingsDto?, + @SerializedName("featureToggles") + val featureToggles: Map? ) internal data class OperationDto( @@ -120,6 +131,8 @@ internal data class InAppDto( val targeting: TreeTargetingDto?, @SerializedName("form") val form: FormDto?, + @SerializedName(InAppTagsDeserializer.TAGS) + val tags: Map?, ) internal sealed class FrequencyDto { @@ -213,5 +226,8 @@ internal data class InAppConfigResponseBlank( // FormDto. Parsed after filtering inApp versions. @SerializedName("form") val form: JsonObject?, + @SerializedName("tags") + @JsonAdapter(InAppTagsDeserializer::class) + val tags: Map?, ) } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/network/MindboxServiceGenerator.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/network/MindboxServiceGenerator.kt index aafc85d37..5314ff6be 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/network/MindboxServiceGenerator.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/network/MindboxServiceGenerator.kt @@ -5,6 +5,7 @@ import cloud.mindbox.mobile_sdk.di.MindboxDI import cloud.mindbox.mobile_sdk.logger.mindboxLogD import cloud.mindbox.mobile_sdk.models.MindboxRequest import cloud.mindbox.mobile_sdk.utils.LoggingExceptionHandler +import com.android.volley.Request import com.android.volley.RequestQueue import com.android.volley.VolleyLog import kotlinx.coroutines.launch @@ -35,6 +36,16 @@ internal class MindboxServiceGenerator(private val requestQueue: RequestQueue) { } } + internal fun addToRequestQueue(request: Request<*>) = LoggingExceptionHandler.runCatching { + requestQueue.add(request) + mindboxLogD( + """ + ---> Method: ${request.method} ${request.url} + ---> End of request + """.trimIndent() + ) + } + private fun logMindboxRequest(request: MindboxRequest) { LoggingExceptionHandler.runCatching { val builder = StringBuilder() 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 8c8500155..cf325fdf5 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 @@ -36,6 +36,9 @@ internal object MindboxPreferences { private const val KEY_SDK_VERSION_CODE = "key_sdk_version_code" private const val KEY_LAST_INFO_UPDATE_TIME = "key_last_info_update_time" private const val KEY_LAST_INAPP_CHANGE_STATE_TIME = "key_last_inapp_change_state_time" + 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 val prefScope = CoroutineScope(Dispatchers.Default) @@ -233,6 +236,17 @@ internal object MindboxPreferences { } } + var firstInitializationTime: String? + get() = loggingRunCatching(defaultValue = null) { + SharedPreferencesManager.getString(KEY_FIRST_INITIALIZATION_TIME) + ?.takeIf { value -> value.isNotBlank() } + } + set(value) { + loggingRunCatching { + SharedPreferencesManager.put(KEY_FIRST_INITIALIZATION_TIME, value) + } + } + var lastInfoUpdateTime: Long? get() = loggingRunCatching(defaultValue = null) { SharedPreferencesManager.getLong(KEY_LAST_INFO_UPDATE_TIME) @@ -252,4 +266,14 @@ internal object MindboxPreferences { SharedPreferencesManager.put(KEY_LAST_INAPP_CHANGE_STATE_TIME, value.ms) } } + + var localStateVersion: Int + get() = loggingRunCatching(defaultValue = DEFAULT_LOCAL_STATE_VERSION) { + SharedPreferencesManager.getInt(KEY_LOCAL_STATE_VERSION, DEFAULT_LOCAL_STATE_VERSION) + } + set(value) { + loggingRunCatching { + SharedPreferencesManager.put(KEY_LOCAL_STATE_VERSION, value) + } + } } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/utils/Constants.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/utils/Constants.kt index d716d7008..73d9c1ebe 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/utils/Constants.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/utils/Constants.kt @@ -1,12 +1,12 @@ package cloud.mindbox.mobile_sdk.utils internal object Constants { - internal const val SDK_VERSION_NUMERIC = 11 + internal const val SDK_VERSION_NUMERIC = 12 internal const val TYPE_JSON_NAME = "\$type" internal const val POST_NOTIFICATION = "android.permission.POST_NOTIFICATIONS" internal const val NOTIFICATION_SETTINGS = "android.settings.APP_NOTIFICATION_SETTINGS" internal const val APP_PACKAGE_NAME = "app_package" internal const val APP_UID_NAME = "app_uid" internal const val SCHEME_PACKAGE = "package" - internal const val SDK_VERSION_CODE = 3 + internal const val SDK_VERSION_CODE = 4 } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/utils/MigrationManager.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/utils/MigrationManager.kt index a4e490f4f..74559e7b9 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/utils/MigrationManager.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/utils/MigrationManager.kt @@ -8,6 +8,8 @@ import cloud.mindbox.mobile_sdk.fromJsonTyped import cloud.mindbox.mobile_sdk.logger.MindboxLog import cloud.mindbox.mobile_sdk.logger.mindboxLogI import cloud.mindbox.mobile_sdk.managers.SharedPreferencesManager +import cloud.mindbox.mobile_sdk.models.convertToIso8601String +import cloud.mindbox.mobile_sdk.models.toTimestamp import cloud.mindbox.mobile_sdk.pushes.PrefPushToken import cloud.mindbox.mobile_sdk.repository.MindboxPreferences import cloud.mindbox.mobile_sdk.toJsonTyped @@ -23,6 +25,7 @@ internal class MigrationManager(val context: Context) : MindboxLog { private var isMigrating = false private val gson by mindboxInject { gson } + private val timeProvider by mindboxInject { timeProvider } suspend fun migrateAll() { if (isMigrating) return @@ -36,7 +39,8 @@ internal class MigrationManager(val context: Context) : MindboxLog { listOf( version290(), version2120(), - version2140() + version2140(), + version2150() ).filter { it.isNeeded } .onEach { migration -> val job = Mindbox.mindboxScope.launch { @@ -144,4 +148,28 @@ internal class MigrationManager(val context: Context) : MindboxLog { MindboxPreferences.versionCode = VERSION_CODE } } + + private fun version2150() = object : Migration { + val VERSION_CODE = 4 + + override val description: String + get() = "Stores the first SDK initialization time" + override val isNeeded: Boolean + get() = (MindboxPreferences.versionCode ?: 0) < VERSION_CODE + + override suspend fun run() { + if (MindboxPreferences.firstInitializationTime == null) { + val firstInitTimestamp = MindboxPreferences.pushTokens.values + .map { token -> token.updateDate } + .filter { timestamp -> timestamp > 0L } + .minOrNull() + ?: timeProvider.currentTimestamp().ms + MindboxPreferences.firstInitializationTime = + firstInitTimestamp + .toTimestamp() + .convertToIso8601String() + } + MindboxPreferences.versionCode = VERSION_CODE + } + } } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/utils/TimeProvider.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/utils/TimeProvider.kt index 30d1107d6..2313be075 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/utils/TimeProvider.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/utils/TimeProvider.kt @@ -1,5 +1,6 @@ package cloud.mindbox.mobile_sdk.utils +import cloud.mindbox.mobile_sdk.models.Milliseconds import cloud.mindbox.mobile_sdk.models.Timestamp import cloud.mindbox.mobile_sdk.models.toTimestamp @@ -7,10 +8,14 @@ internal interface TimeProvider { fun currentTimeMillis(): Long fun currentTimestamp(): Timestamp + + fun elapsedSince(startTimeMillis: Timestamp): Milliseconds } internal class SystemTimeProvider : TimeProvider { override fun currentTimeMillis() = System.currentTimeMillis() override fun currentTimestamp() = System.currentTimeMillis().toTimestamp() + + override fun elapsedSince(startTimeMillis: Timestamp): Milliseconds = Milliseconds(currentTimeMillis() - startTimeMillis.ms) } diff --git a/sdk/src/main/res/values/strings.xml b/sdk/src/main/res/values/strings.xml index ba29a08a9..acb6bfed1 100644 --- a/sdk/src/main/res/values/strings.xml +++ b/sdk/src/main/res/values/strings.xml @@ -13,5 +13,7 @@ true @null @android:color/transparent + false + false \ No newline at end of file diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/ExtensionsTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/ExtensionsTest.kt index 7ac4dae67..b5d18b595 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/ExtensionsTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/ExtensionsTest.kt @@ -3,6 +3,8 @@ package cloud.mindbox.mobile_sdk import android.content.Context import android.content.Intent import androidx.test.core.app.ApplicationProvider +import cloud.mindbox.mobile_sdk.models.Timestamp +import cloud.mindbox.mobile_sdk.models.convertToIso8601String import com.android.volley.NetworkResponse import com.android.volley.VolleyError import com.jakewharton.threetenabp.AndroidThreeTen @@ -13,6 +15,7 @@ import org.junit.Assert.* import org.junit.Before import org.junit.Test import org.junit.runner.RunWith +import org.threeten.bp.Instant import org.robolectric.RobolectricTestRunner import org.threeten.bp.LocalDateTime import org.threeten.bp.ZoneId @@ -50,6 +53,18 @@ internal class ExtensionsTest { assertEquals(expectedResult, actualResult) } + @Test + fun `converting timestamp to ISO 8601 string`() { + val time = Timestamp(1_736_501_200_000L) + val expectedResult: String = ZonedDateTime.ofInstant( + Instant.ofEpochMilli(time.ms), + ZoneOffset.UTC + ).convertToString() + val actualResult: String = time.convertToIso8601String() + + assertEquals(expectedResult, actualResult) + } + private val testPackageName = "com.test.app" private val customProcessName = "com.test.app:myprocess" private val context = mockk { diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/MindboxTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/MindboxTest.kt index 382f91dab..9be1cb9d9 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/MindboxTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/MindboxTest.kt @@ -2,6 +2,7 @@ package cloud.mindbox.mobile_sdk import android.app.Application import android.content.Context +import cloud.mindbox.mobile_sdk.di.MindboxDI import cloud.mindbox.mobile_sdk.managers.MindboxEventManager import cloud.mindbox.mobile_sdk.models.InitData import cloud.mindbox.mobile_sdk.models.TokenData @@ -43,6 +44,8 @@ class MindboxTest { @Before fun setUp() { + mockkObject(MindboxDI) + every { MindboxDI.appModule } returns mockk(relaxed = true) mockkObject(MindboxPreferences) mockkObject(PushNotificationManager) mockkObject(MindboxEventManager) @@ -55,6 +58,8 @@ class MindboxTest { every { MindboxPreferences.isNotificationEnabled } returns true every { MindboxPreferences.instanceId } returns "instanceId" every { MindboxPreferences.deviceUuid } returns "deviceUUID" + every { MindboxPreferences.firstInitializationTime } returns null + every { MindboxPreferences.firstInitializationTime = any() } just runs every { MindboxPreferences.infoUpdatedVersion } returns 1 Mindbox.pushServiceHandlers = listOf(firstProvider, secondProvider, thirdProvider) @@ -242,6 +247,28 @@ class MindboxTest { } } + @Test + fun `firstInitialization does not override saved first initialization time`() = runTest { + every { MindboxPreferences.firstInitializationTime } returns "2025-01-10T07:40:00Z" + + Mindbox.firstInitialization(context, mockk(relaxed = true)) + + verify(exactly = 0) { + MindboxPreferences.firstInitializationTime = any() + } + } + + @Test + fun `firstInitializationDateTime saved when first initialization time`() = runTest { + every { MindboxPreferences.firstInitializationTime } returns null + + Mindbox.firstInitialization(context, mockk(relaxed = true)) + + verify(exactly = 1) { + MindboxPreferences.firstInitializationTime = any() + } + } + @Test fun `getPushTokensSaveDate returns correctly map`() { val tokensDate = Mindbox.getPushTokensSaveDate() diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/dto/deserializers/FeatureTogglesDtoBlankDeserializerTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/dto/deserializers/FeatureTogglesDtoBlankDeserializerTest.kt new file mode 100644 index 000000000..453a5ff96 --- /dev/null +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/dto/deserializers/FeatureTogglesDtoBlankDeserializerTest.kt @@ -0,0 +1,160 @@ +package cloud.mindbox.mobile_sdk.inapp.data.dto.deserializers + +import cloud.mindbox.mobile_sdk.inapp.data.managers.SEND_INAPP_SHOW_ERROR_FEATURE +import cloud.mindbox.mobile_sdk.models.operation.response.SettingsDtoBlank +import com.google.gson.Gson +import com.google.gson.GsonBuilder +import com.google.gson.JsonArray +import com.google.gson.JsonNull +import com.google.gson.JsonObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +internal class FeatureTogglesDtoBlankDeserializerTest { + private lateinit var gson: Gson + + @Before + fun setup() { + gson = GsonBuilder() + .create() + } + + @Test + fun `deserialize valid true value`() { + val json = JsonObject().apply { + addProperty("MobileSdkShouldSendInAppShowError", true) + } + + val result = gson.fromJson(json, SettingsDtoBlank.FeatureTogglesDtoBlank::class.java) + + assertEquals(true, result.toggles[SEND_INAPP_SHOW_ERROR_FEATURE]) + } + + @Test + fun `deserialize valid false value`() { + val json = JsonObject().apply { + addProperty("MobileSdkShouldSendInAppShowError", false) + } + + val result = gson.fromJson(json, SettingsDtoBlank.FeatureTogglesDtoBlank::class.java) + + assertEquals(false, result.toggles[SEND_INAPP_SHOW_ERROR_FEATURE]) + } + + @Test + fun `deserialize multiple keys`() { + val json = JsonObject().apply { + addProperty("MobileSdkShouldSendInAppShowError", true) + addProperty("anotherToggle", false) + } + + val result = gson.fromJson(json, SettingsDtoBlank.FeatureTogglesDtoBlank::class.java) + + assertEquals(true, result.toggles[SEND_INAPP_SHOW_ERROR_FEATURE]) + assertEquals(false, result.toggles["anotherToggle"]) + } + + @Test + fun `deserialize string true value`() { + val json = JsonObject().apply { + addProperty("MobileSdkShouldSendInAppShowError", "true") + } + + val result = gson.fromJson(json, SettingsDtoBlank.FeatureTogglesDtoBlank::class.java) + + assertNull(result.toggles[SEND_INAPP_SHOW_ERROR_FEATURE]) + } + + @Test + fun `deserialize string false value`() { + val json = JsonObject().apply { + addProperty("MobileSdkShouldSendInAppShowError", "false") + } + + val result = gson.fromJson(json, SettingsDtoBlank.FeatureTogglesDtoBlank::class.java) + + assertNull(result.toggles[SEND_INAPP_SHOW_ERROR_FEATURE]) + } + + @Test + fun `deserialize number 1 value`() { + val json = JsonObject().apply { + addProperty("MobileSdkShouldSendInAppShowError", 1) + } + + val result = gson.fromJson(json, SettingsDtoBlank.FeatureTogglesDtoBlank::class.java) + + assertNull(result.toggles[SEND_INAPP_SHOW_ERROR_FEATURE]) + } + + @Test + fun `deserialize invalid string value`() { + val json = JsonObject().apply { + addProperty("MobileSdkShouldSendInAppShowError", "invalid") + } + + val result = gson.fromJson(json, SettingsDtoBlank.FeatureTogglesDtoBlank::class.java) + + assertNull(result.toggles[SEND_INAPP_SHOW_ERROR_FEATURE]) + } + + @Test + fun `deserialize object value`() { + val json = JsonObject().apply { + add("MobileSdkShouldSendInAppShowError", JsonObject().apply { + addProperty("value", true) + }) + } + + val result = gson.fromJson(json, SettingsDtoBlank.FeatureTogglesDtoBlank::class.java) + + assertNull(result.toggles[SEND_INAPP_SHOW_ERROR_FEATURE]) + } + + @Test + fun `deserialize array value`() { + val json = JsonObject().apply { + add("MobileSdkShouldSendInAppShowError", JsonArray().apply { + add(true) + }) + } + + val result = gson.fromJson(json, SettingsDtoBlank.FeatureTogglesDtoBlank::class.java) + + assertNull(result.toggles[SEND_INAPP_SHOW_ERROR_FEATURE]) + } + + @Test + fun `deserialize empty string value`() { + val json = JsonObject().apply { + addProperty("MobileSdkShouldSendInAppShowError", "") + } + + val result = gson.fromJson(json, SettingsDtoBlank.FeatureTogglesDtoBlank::class.java) + + assertNull(result.toggles[SEND_INAPP_SHOW_ERROR_FEATURE]) + } + + @Test + fun `deserialize missing key`() { + val json = JsonObject() + + val result = gson.fromJson(json, SettingsDtoBlank.FeatureTogglesDtoBlank::class.java) + + assertTrue(result.toggles.isEmpty()) + } + + @Test + fun `deserialize null value`() { + val json = JsonObject().apply { + add("MobileSdkShouldSendInAppShowError", JsonNull.INSTANCE) + } + + val result = gson.fromJson(json, SettingsDtoBlank.FeatureTogglesDtoBlank::class.java) + + assertNull(result.toggles[SEND_INAPP_SHOW_ERROR_FEATURE]) + } +} diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/dto/deserializers/InAppTagsDeserializerTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/dto/deserializers/InAppTagsDeserializerTest.kt new file mode 100644 index 000000000..5d03521fe --- /dev/null +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/dto/deserializers/InAppTagsDeserializerTest.kt @@ -0,0 +1,101 @@ +package cloud.mindbox.mobile_sdk.inapp.data.dto.deserializers + +import cloud.mindbox.mobile_sdk.fromJsonTyped +import com.google.gson.Gson +import com.google.gson.GsonBuilder +import com.google.gson.reflect.TypeToken +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +internal class InAppTagsDeserializerTest { + + private lateinit var gson: Gson + + @Before + fun setUp() { + val mapType = object : TypeToken?>() {}.type + gson = GsonBuilder() + .registerTypeAdapter(mapType, InAppTagsDeserializer()) + .create() + } + + private fun deserialize(json: String): Map? = + gson.fromJsonTyped?>(json) + + @Test + fun `deserialize returns string values as is`() { + val inputJson = """{"layer": "webView", "type": "onboarding"}""" + val actualResult = deserialize(inputJson) + assertEquals(mapOf("layer" to "webView", "type" to "onboarding"), actualResult) + } + + @Test + fun `deserialize skips number values`() { + val inputJson = """{"layer": "webView", "count": 42}""" + val actualResult = deserialize(inputJson) + assertEquals(mapOf("layer" to "webView"), actualResult) + } + + @Test + fun `deserialize skips boolean values`() { + val inputJson = """{"layer": "webView", "isActive": true}""" + val actualResult = deserialize(inputJson) + assertEquals(mapOf("layer" to "webView"), actualResult) + } + + @Test + fun `deserialize skips object values`() { + val inputJson = """{"layer": "webView", "nested": {"key": "value"}}""" + val actualResult = deserialize(inputJson) + assertEquals(mapOf("layer" to "webView"), actualResult) + } + + @Test + fun `deserialize skips null values`() { + val inputJson = """{"layer": "webView", "nullKey": null}""" + val actualResult = deserialize(inputJson) + assertEquals(mapOf("layer" to "webView"), actualResult) + } + + @Test + fun `deserialize returns null when json is null`() { + val actualResult = deserialize("null") + assertNull(actualResult) + } + + @Test + fun `deserialize returns null when json is not an object`() { + val actualResult = deserialize("""["item1", "item2"]""") + assertNull(actualResult) + } + + @Test + fun `deserialize returns empty map when all values are non-string`() { + val inputJson = """{"count": 42, "flag": true, "nested": {}}""" + val actualResult = deserialize(inputJson) + assertTrue(actualResult?.isEmpty() == true) + } + + @Test + fun `deserialize returns empty map for empty object`() { + val actualResult = deserialize("{}") + assertTrue(actualResult?.isEmpty() == true) + } + + @Test + fun `deserialize preserves empty string values`() { + val inputJson = """{"layer": "", "type": "onboarding"}""" + val actualResult = deserialize(inputJson) + assertEquals(mapOf("layer" to "", "type" to "onboarding"), actualResult) + } + + @Test + fun `deserialize skips array values`() { + val inputJson = """{"layer": "webView", "items": [1, 2, 3]}""" + val actualResult = deserialize(inputJson) + assertEquals(mapOf("layer" to "webView"), actualResult) + } +} diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/dto/deserializers/WebViewParamsDeserializerTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/dto/deserializers/WebViewParamsDeserializerTest.kt new file mode 100644 index 000000000..2179898bc --- /dev/null +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/dto/deserializers/WebViewParamsDeserializerTest.kt @@ -0,0 +1,131 @@ +package cloud.mindbox.mobile_sdk.inapp.data.dto.deserializers + +import cloud.mindbox.mobile_sdk.inapp.data.dto.BackgroundDto +import com.google.gson.Gson +import com.google.gson.JsonArray +import com.google.gson.JsonNull +import com.google.gson.JsonObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +internal class WebViewParamsDeserializerTest { + + private lateinit var gson: Gson + + @Before + fun setUp() { + gson = Gson() + } + + @Test + fun `deserialize converts all values to string`() { + val nestedObject = JsonObject().apply { addProperty("nested", "value") } + val json = JsonObject().apply { + addProperty("formId", "73379") + addProperty("validKey", "validValue") + addProperty("numberKey", 123) + add("objectKey", nestedObject) + add("nullKey", JsonNull.INSTANCE) + } + val webViewLayerJson = createWebViewLayerJson( + baseUrl = "https://inapp.local", + contentUrl = "https://api.example.com", + params = json + ) + val result = gson.fromJson(webViewLayerJson, BackgroundDto.LayerDto.WebViewLayerDto::class.java) + assertEquals("73379", result.params!!["formId"]) + assertEquals("validValue", result.params["validKey"]) + assertEquals("123", result.params["numberKey"]) + assertEquals("{\"nested\":\"value\"}", result.params["objectKey"]) + assertFalse(result.params.containsKey("nullKey")) + } + + @Test + fun `deserialize returns null when params is null`() { + val webViewLayerJson = JsonObject().apply { + addProperty("baseUrl", "https://inapp.local") + addProperty("contentUrl", "https://api.example.com") + addProperty("\$type", "webview") + add("params", JsonNull.INSTANCE) + } + val result = gson.fromJson(webViewLayerJson, BackgroundDto.LayerDto.WebViewLayerDto::class.java) + assertNull(result.params) + } + + @Test + fun `deserialize returns empty map when params is not object`() { + val webViewLayerJson = JsonObject().apply { + addProperty("baseUrl", "https://inapp.local") + addProperty("contentUrl", "https://api.example.com") + addProperty("\$type", "webview") + add("params", JsonArray().apply { add("notAnObject") }) + } + val result = gson.fromJson(webViewLayerJson, BackgroundDto.LayerDto.WebViewLayerDto::class.java) + assertTrue(result.params?.isEmpty() == true) + } + + @Test + fun `deserialize converts number and boolean primitive to string`() { + val json = JsonObject().apply { + addProperty("stringVal", "ok") + addProperty("intVal", 42) + addProperty("doubleVal", 3.14) + addProperty("boolVal", true) + } + val webViewLayerJson = createWebViewLayerJson( + baseUrl = "https://inapp.local", + contentUrl = "https://api.example.com", + params = json + ) + val result = gson.fromJson(webViewLayerJson, BackgroundDto.LayerDto.WebViewLayerDto::class.java) + assertEquals("ok", result.params!!["stringVal"]) + assertEquals("42", result.params["intVal"]) + assertEquals("3.14", result.params["doubleVal"]) + assertEquals("true", result.params["boolVal"]) + } + + @Test + fun `deserialize empty object returns empty map`() { + val webViewLayerJson = createWebViewLayerJson( + baseUrl = "https://inapp.local", + contentUrl = "https://api.example.com", + params = JsonObject() + ) + val result = gson.fromJson(webViewLayerJson, BackgroundDto.LayerDto.WebViewLayerDto::class.java) + assertTrue(result.params?.isEmpty() == true) + } + + @Test + fun `deserialize preserves all string values`() { + val json = JsonObject().apply { + addProperty("key1", "value1") + addProperty("key2", "") + addProperty("key3", "value3") + } + val webViewLayerJson = createWebViewLayerJson( + baseUrl = "https://inapp.local", + contentUrl = "https://api.example.com", + params = json + ) + val result = gson.fromJson(webViewLayerJson, BackgroundDto.LayerDto.WebViewLayerDto::class.java) + assertEquals( + mapOf("key1" to "value1", "key2" to "", "key3" to "value3"), + result.params + ) + } + + private fun createWebViewLayerJson( + baseUrl: String, + contentUrl: String, + params: JsonObject + ): JsonObject = JsonObject().apply { + addProperty("baseUrl", baseUrl) + addProperty("contentUrl", contentUrl) + addProperty("\$type", "webview") + add("params", params) + } +} diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/FeatureToggleManagerImplTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/FeatureToggleManagerImplTest.kt new file mode 100644 index 000000000..ed69260ce --- /dev/null +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/FeatureToggleManagerImplTest.kt @@ -0,0 +1,297 @@ +package cloud.mindbox.mobile_sdk.inapp.data.managers + +import cloud.mindbox.mobile_sdk.models.operation.response.InAppConfigResponse +import cloud.mindbox.mobile_sdk.models.operation.response.SettingsDto +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test + +class FeatureToggleManagerImplTest { + + private lateinit var featureToggleManager: FeatureToggleManagerImpl + + @Before + fun onTestStart() { + featureToggleManager = FeatureToggleManagerImpl() + } + + @Test + fun `applyToggles sets shouldSendInAppShowError to true when featureToggles contains true`() { + val config = InAppConfigResponse( + inApps = null, + monitoring = null, + settings = SettingsDto( + operations = null, + ttl = null, + slidingExpiration = null, + inapp = null, + featureToggles = mapOf("MobileSdkShouldSendInAppShowError" to true) + ), + abtests = null + ) + + featureToggleManager.applyToggles(config) + + assertEquals(true, featureToggleManager.isEnabled(SEND_INAPP_SHOW_ERROR_FEATURE)) + } + + @Test + fun `applyToggles works with feature name containing special characters`() { + val config = InAppConfigResponse( + inApps = null, + monitoring = null, + settings = SettingsDto( + operations = null, + ttl = null, + slidingExpiration = null, + inapp = null, + featureToggles = mapOf("!@#$%^&*()_+<>:;{},./|~`" to false) + ), + abtests = null + ) + + featureToggleManager.applyToggles(config) + + assertEquals(false, featureToggleManager.isEnabled("!@#$%^&*()_+<>:;{},./|~`")) + } + + @Test + fun `applyToggles sets shouldSendInAppShowError to false when featureToggles contains false`() { + val config = InAppConfigResponse( + inApps = null, + monitoring = null, + settings = SettingsDto( + operations = null, + ttl = null, + slidingExpiration = null, + inapp = null, + featureToggles = mapOf("MobileSdkShouldSendInAppShowError" to false) + ), + abtests = null + ) + + featureToggleManager.applyToggles(config) + + assertEquals(false, featureToggleManager.isEnabled(SEND_INAPP_SHOW_ERROR_FEATURE)) + } + + @Test + fun `applyToggles handles multiple toggles`() { + val config = InAppConfigResponse( + inApps = null, + monitoring = null, + settings = SettingsDto( + operations = null, + ttl = null, + slidingExpiration = null, + inapp = null, + featureToggles = mapOf( + "MobileSdkShouldSendInAppShowError" to true, + "anotherToggle" to false + ) + ), + abtests = null + ) + + featureToggleManager.applyToggles(config) + + assertEquals(true, featureToggleManager.isEnabled(SEND_INAPP_SHOW_ERROR_FEATURE)) + assertEquals(false, featureToggleManager.isEnabled("anotherToggle")) + } + + @Test + fun `applyToggles return true when null values in featureToggles map`() { + val config = InAppConfigResponse( + inApps = null, + monitoring = null, + settings = SettingsDto( + operations = null, + ttl = null, + slidingExpiration = null, + inapp = null, + featureToggles = mapOf( + "MobileSdkShouldSendInAppShowError" to true, + "invalidToggle" to null + ) + ), + abtests = null + ) + + featureToggleManager.applyToggles(config) + + assertEquals(true, featureToggleManager.isEnabled(SEND_INAPP_SHOW_ERROR_FEATURE)) + assertEquals(true, featureToggleManager.isEnabled("invalidToggle")) + } + + @Test + fun `applyToggles returns true when featureToggles is null`() { + val config = InAppConfigResponse( + inApps = null, + monitoring = null, + settings = SettingsDto( + operations = null, + ttl = null, + slidingExpiration = null, + inapp = null, + featureToggles = null + ), + abtests = null + ) + + featureToggleManager.applyToggles(config) + + assertEquals(true, featureToggleManager.isEnabled(SEND_INAPP_SHOW_ERROR_FEATURE)) + } + + @Test + fun `applyToggles returns true when settings is null`() { + val config = InAppConfigResponse( + inApps = null, + monitoring = null, + settings = null, + abtests = null + ) + + featureToggleManager.applyToggles(config) + + assertEquals(true, featureToggleManager.isEnabled(SEND_INAPP_SHOW_ERROR_FEATURE)) + } + + @Test + fun `applyToggles returns true when config is null`() { + featureToggleManager.applyToggles(null) + + assertEquals(true, featureToggleManager.isEnabled(SEND_INAPP_SHOW_ERROR_FEATURE)) + } + + @Test + fun `isEnabled returns true by default`() { + assertEquals(true, featureToggleManager.isEnabled(SEND_INAPP_SHOW_ERROR_FEATURE)) + } + + @Test + fun `applyToggles can change value from true to false`() { + val configTrue = InAppConfigResponse( + inApps = null, + monitoring = null, + settings = SettingsDto( + operations = null, + ttl = null, + slidingExpiration = null, + inapp = null, + featureToggles = mapOf("MobileSdkShouldSendInAppShowError" to true) + ), + abtests = null + ) + featureToggleManager.applyToggles(configTrue) + assertEquals(true, featureToggleManager.isEnabled(SEND_INAPP_SHOW_ERROR_FEATURE)) + + val configFalse = InAppConfigResponse( + inApps = null, + monitoring = null, + settings = SettingsDto( + operations = null, + ttl = null, + slidingExpiration = null, + inapp = null, + featureToggles = mapOf("MobileSdkShouldSendInAppShowError" to false) + ), + abtests = null + ) + featureToggleManager.applyToggles(configFalse) + assertEquals(false, featureToggleManager.isEnabled(SEND_INAPP_SHOW_ERROR_FEATURE)) + } + + @Test + fun `applyToggles can change value from false to true`() { + val configFalse = InAppConfigResponse( + inApps = null, + monitoring = null, + settings = SettingsDto( + operations = null, + ttl = null, + slidingExpiration = null, + inapp = null, + featureToggles = mapOf("MobileSdkShouldSendInAppShowError" to false) + ), + abtests = null + ) + featureToggleManager.applyToggles(configFalse) + assertEquals(false, featureToggleManager.isEnabled(SEND_INAPP_SHOW_ERROR_FEATURE)) + + val configTrue = InAppConfigResponse( + inApps = null, + monitoring = null, + settings = SettingsDto( + operations = null, + ttl = null, + slidingExpiration = null, + inapp = null, + featureToggles = mapOf("MobileSdkShouldSendInAppShowError" to true) + ), + abtests = null + ) + featureToggleManager.applyToggles(configTrue) + assertEquals(true, featureToggleManager.isEnabled(SEND_INAPP_SHOW_ERROR_FEATURE)) + } + + @Test + fun `applyToggles clears previous toggles when null config is applied`() { + val configTrue = InAppConfigResponse( + inApps = null, + monitoring = null, + settings = SettingsDto( + operations = null, + ttl = null, + slidingExpiration = null, + inapp = null, + featureToggles = mapOf("MobileSdkShouldSendInAppShowError" to false) + ), + abtests = null + ) + featureToggleManager.applyToggles(configTrue) + assertEquals(false, featureToggleManager.isEnabled(SEND_INAPP_SHOW_ERROR_FEATURE)) + + featureToggleManager.applyToggles(null) + assertEquals(true, featureToggleManager.isEnabled(SEND_INAPP_SHOW_ERROR_FEATURE)) + } + + @Test + fun `applyToggles clears previous toggles when new config is applied`() { + val config1 = InAppConfigResponse( + inApps = null, + monitoring = null, + settings = SettingsDto( + operations = null, + ttl = null, + slidingExpiration = null, + inapp = null, + featureToggles = mapOf( + "MobileSdkShouldSendInAppShowError" to false, + "toggle1" to true + ) + ), + abtests = null + ) + featureToggleManager.applyToggles(config1) + assertEquals(false, featureToggleManager.isEnabled(SEND_INAPP_SHOW_ERROR_FEATURE)) + assertEquals(true, featureToggleManager.isEnabled("toggle1")) + + val config2 = InAppConfigResponse( + inApps = null, + monitoring = null, + settings = SettingsDto( + operations = null, + ttl = null, + slidingExpiration = null, + inapp = null, + featureToggles = mapOf("toggle2" to false) + ), + abtests = null + ) + featureToggleManager.applyToggles(config2) + assertEquals(true, featureToggleManager.isEnabled(SEND_INAPP_SHOW_ERROR_FEATURE)) + assertEquals(true, featureToggleManager.isEnabled("toggle1")) + assertEquals(false, featureToggleManager.isEnabled("toggle2")) + } +} diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppFailureTrackerImplTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppFailureTrackerImplTest.kt new file mode 100644 index 000000000..3638f2e16 --- /dev/null +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppFailureTrackerImplTest.kt @@ -0,0 +1,222 @@ +package cloud.mindbox.mobile_sdk.inapp.data.managers + +import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.FeatureToggleManager +import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.repositories.InAppRepository +import cloud.mindbox.mobile_sdk.models.operation.request.FailureReason +import cloud.mindbox.mobile_sdk.models.operation.request.InAppShowFailure +import cloud.mindbox.mobile_sdk.utils.TimeProvider +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test + +internal class InAppFailureTrackerImplTest { + + private val timeProvider: TimeProvider = mockk() + private val inAppRepository: InAppRepository = mockk(relaxed = true) + private val featureToggleManager: FeatureToggleManager = mockk() + private lateinit var inAppFailureTracker: InAppFailureTrackerImpl + + private val inAppId = "testInAppId" + private val currentTimeMillis = 1707523200000L + private val expectedTimestamp = "2024-02-10T00:00:00Z" + + @Before + fun onTestStart() { + every { timeProvider.currentTimeMillis() } returns currentTimeMillis + inAppFailureTracker = InAppFailureTrackerImpl( + timeProvider = timeProvider, + inAppRepository = inAppRepository, + featureToggleManager = featureToggleManager + ) + } + + @Test + fun `collectFailure does not send immediately`() { + every { featureToggleManager.isEnabled(SEND_INAPP_SHOW_ERROR_FEATURE) } returns true + + inAppFailureTracker.collectFailure( + inAppId = inAppId, + failureReason = FailureReason.PRESENTATION_FAILED, + errorDetails = "error" + ) + + verify(exactly = 0) { inAppRepository.sendInAppShowFailure(any()) } + } + + @Test + fun `sendFailure sends immediately when feature toggle is enabled`() { + every { featureToggleManager.isEnabled(SEND_INAPP_SHOW_ERROR_FEATURE) } returns true + val slot = slot>() + + inAppFailureTracker.sendFailure( + inAppId = inAppId, + failureReason = FailureReason.PRESENTATION_FAILED, + errorDetails = "error" + ) + + verify(exactly = 1) { inAppRepository.sendInAppShowFailure(capture(slot)) } + val captured = slot.captured + assertEquals(1, captured.size) + assertEquals(inAppId, captured[0].inAppId) + assertEquals(FailureReason.PRESENTATION_FAILED, captured[0].failureReason) + assertEquals("error", captured[0].errorDetails) + assertEquals(expectedTimestamp, captured[0].dateTimeUtc) + } + + @Test + fun `sendFailure does not send when feature toggle is disabled`() { + every { featureToggleManager.isEnabled(SEND_INAPP_SHOW_ERROR_FEATURE) } returns false + + inAppFailureTracker.sendFailure( + inAppId = inAppId, + failureReason = FailureReason.PRESENTATION_FAILED, + errorDetails = "error" + ) + + verify(exactly = 0) { inAppRepository.sendInAppShowFailure(any()) } + } + + @Test + fun `collectFailure does not add duplicate when same inAppId already tracked`() { + every { featureToggleManager.isEnabled(SEND_INAPP_SHOW_ERROR_FEATURE) } returns true + val slot = slot>() + + inAppFailureTracker.collectFailure( + inAppId = inAppId, + failureReason = FailureReason.PRESENTATION_FAILED, + errorDetails = "first" + ) + inAppFailureTracker.collectFailure( + inAppId = inAppId, + failureReason = FailureReason.IMAGE_DOWNLOAD_FAILED, + errorDetails = "second" + ) + inAppFailureTracker.sendCollectedFailures() + + verify(exactly = 1) { inAppRepository.sendInAppShowFailure(capture(slot)) } + val captured = slot.captured + assertEquals(1, captured.size) + assertEquals(FailureReason.PRESENTATION_FAILED, captured[0].failureReason) + } + + @Test + fun `sendFailure truncates errorDetails to 1000 chars`() { + every { featureToggleManager.isEnabled(SEND_INAPP_SHOW_ERROR_FEATURE) } returns true + val longErrorDetails = "a".repeat(1500) + val slot = slot>() + + inAppFailureTracker.sendFailure( + inAppId = inAppId, + failureReason = FailureReason.UNKNOWN_ERROR, + errorDetails = longErrorDetails + ) + + verify(exactly = 1) { inAppRepository.sendInAppShowFailure(capture(slot)) } + assertEquals("a".repeat(1000), slot.captured[0].errorDetails) + } + + @Test + fun `collectFailure truncates errorDetails to 1000 chars`() { + every { featureToggleManager.isEnabled(SEND_INAPP_SHOW_ERROR_FEATURE) } returns true + val longErrorDetails = "a".repeat(1500) + val slot = slot>() + + inAppFailureTracker.collectFailure( + inAppId = inAppId, + failureReason = FailureReason.UNKNOWN_ERROR, + errorDetails = longErrorDetails + ) + inAppFailureTracker.sendCollectedFailures() + + verify(exactly = 1) { inAppRepository.sendInAppShowFailure(capture(slot)) } + assertEquals("a".repeat(1000), slot.captured[0].errorDetails) + } + + @Test + fun `sendCollectedFailures sends all failures when feature toggle is enabled`() { + every { featureToggleManager.isEnabled(SEND_INAPP_SHOW_ERROR_FEATURE) } returns true + val slot = slot>() + + inAppFailureTracker.collectFailure( + inAppId = "inApp1", + failureReason = FailureReason.PRESENTATION_FAILED, + errorDetails = null + ) + inAppFailureTracker.collectFailure( + inAppId = "inApp2", + failureReason = FailureReason.IMAGE_DOWNLOAD_FAILED, + errorDetails = "details" + ) + + inAppFailureTracker.sendCollectedFailures() + verify(exactly = 1) { inAppRepository.sendInAppShowFailure(capture(slot)) } + val captured = slot.captured + assertEquals(2, captured.size) + assertEquals(1, captured.count { it.inAppId == "inApp1" && it.failureReason == FailureReason.PRESENTATION_FAILED }) + assertEquals(1, captured.count { it.inAppId == "inApp2" && it.failureReason == FailureReason.IMAGE_DOWNLOAD_FAILED }) + } + + @Test + fun `sendCollectedFailures clears failures after sending`() { + every { featureToggleManager.isEnabled(SEND_INAPP_SHOW_ERROR_FEATURE) } returns true + + inAppFailureTracker.collectFailure( + inAppId = inAppId, + failureReason = FailureReason.PRESENTATION_FAILED, + errorDetails = null + ) + inAppFailureTracker.sendCollectedFailures() + inAppFailureTracker.sendCollectedFailures() + + verify(exactly = 1) { inAppRepository.sendInAppShowFailure(any()) } + } + + @Test + fun `sendCollectedFailures does not send when feature toggle is disabled`() { + every { featureToggleManager.isEnabled(SEND_INAPP_SHOW_ERROR_FEATURE) } returns false + + inAppFailureTracker.collectFailure( + inAppId = inAppId, + failureReason = FailureReason.PRESENTATION_FAILED, + errorDetails = null + ) + inAppFailureTracker.sendCollectedFailures() + + verify(exactly = 0) { inAppRepository.sendInAppShowFailure(any()) } + } + + @Test + fun `clearFailures clears collected failures`() { + every { featureToggleManager.isEnabled(SEND_INAPP_SHOW_ERROR_FEATURE) } returns true + inAppFailureTracker.collectFailure( + inAppId = inAppId, + failureReason = FailureReason.PRESENTATION_FAILED, + errorDetails = null + ) + + inAppFailureTracker.clearFailures() + inAppFailureTracker.sendCollectedFailures() + + verify(exactly = 0) { inAppRepository.sendInAppShowFailure(any()) } + } + + @Test + fun `sendFailure with null errorDetails`() { + every { featureToggleManager.isEnabled(SEND_INAPP_SHOW_ERROR_FEATURE) } returns true + val slot = slot>() + + inAppFailureTracker.sendFailure( + inAppId = inAppId, + failureReason = FailureReason.GEO_TARGETING_FAILED, + errorDetails = null + ) + + verify(exactly = 1) { inAppRepository.sendInAppShowFailure(capture(slot)) } + assertEquals(null, slot.captured[0].errorDetails) + assertEquals(inAppId, slot.captured[0].inAppId) + } +} diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppSerializationManagerTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppSerializationManagerTest.kt index b9e02efb2..71e300061 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppSerializationManagerTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppSerializationManagerTest.kt @@ -1,11 +1,16 @@ package cloud.mindbox.mobile_sdk.inapp.data.managers import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.InAppSerializationManager +import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppFailuresWrapper +import cloud.mindbox.mobile_sdk.models.operation.request.FailureReason +import cloud.mindbox.mobile_sdk.models.operation.request.InAppShowRequest +import cloud.mindbox.mobile_sdk.models.operation.request.InAppShowFailure import com.google.gson.Gson import com.google.gson.reflect.TypeToken import io.mockk.every import io.mockk.mockk import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull import org.junit.Before import org.junit.Test @@ -66,22 +71,49 @@ internal class InAppSerializationManagerTest { } @Test - fun `serialize to inApp handled string success`() { - val expectedResult = "{\"inappid\":\"${inAppId}\"}" - val actualResult = inAppSerializationManager.serializeToInAppHandledString(inAppId) + fun `serializeToInAppActionString returns JSON with inAppId only`() { + val expectedResult = "{\"inappId\":\"${inAppId}\"}" + val actualResult = inAppSerializationManager.serializeToInAppActionString(inAppId) assertEquals(expectedResult, actualResult) } @Test - fun `serialize to inApp handled string error`() { + fun `serializeToInAppActionString returns empty string on error`() { val gson: Gson = mockk() - every { - gson.toJson(any()) - } throws Error("errorMessage") + every { gson.toJson(any(), any>()) } throws Error("errorMessage") inAppSerializationManager = InAppSerializationManagerImpl(gson) - val expectedResult = "" - val actualResult = inAppSerializationManager.serializeToInAppHandledString(inAppId) - assertEquals(expectedResult, actualResult) + val actualResult = inAppSerializationManager.serializeToInAppActionString(inAppId) + assertEquals("", actualResult) + } + + @Test + fun `serializeToInAppShownString returns JSON with inAppId timeToDisplay and tags`() { + val timeToDisplay = "0:00:00:00.2250000" + val tags = mapOf("layer" to "webView", "type" to "onboarding") + val actualResult = inAppSerializationManager.serializeToInAppShownActionString(inAppId, timeToDisplay, tags) + val parsed = gson.fromJson(actualResult, InAppShowRequest::class.java) + assertEquals(inAppId, parsed.inAppId) + assertEquals(timeToDisplay, parsed.timeToDisplay) + assertEquals(tags, parsed.tags) + } + + @Test + fun `serializeToInAppShownString omits tags when null`() { + val timeToDisplay = "0:00:00:00.2250000" + val actualResult = inAppSerializationManager.serializeToInAppShownActionString(inAppId, timeToDisplay, null) + val parsed = gson.fromJson(actualResult, InAppShowRequest::class.java) + assertEquals(inAppId, parsed.inAppId) + assertEquals(timeToDisplay, parsed.timeToDisplay) + assertNull(parsed.tags) + } + + @Test + fun `serializeToInAppShownString returns empty string on error`() { + val gson: Gson = mockk() + every { gson.toJson(any(), any>()) } throws Error("errorMessage") + inAppSerializationManager = InAppSerializationManagerImpl(gson) + val actualResult = inAppSerializationManager.serializeToInAppShownActionString(inAppId, "0:00:00:00.2250000", null) + assertEquals("", actualResult) } @Test @@ -123,4 +155,42 @@ internal class InAppSerializationManagerTest { val actualResult = inAppSerializationManager.deserializeToShownInApps(testString) assertEquals(expectedResult, actualResult) } + + @Test + fun `serializeToInAppShowFailuresString returns valid JSON string`() { + val inAppShowFailures = listOf( + InAppShowFailure( + inAppId = inAppId, + failureReason = FailureReason.PRESENTATION_FAILED, + errorDetails = "error", + dateTimeUtc = "2024-02-10T00:00:00Z" + ) + ) + val expectedJson = Gson().toJson(InAppFailuresWrapper(inAppShowFailures)) + + val actualJson = inAppSerializationManager.serializeToInAppShowFailuresString(inAppShowFailures) + + assertEquals(expectedJson, actualJson) + } + + @Test + fun `serializeToInAppShowFailuresString returns empty string when exception occurs`() { + val gson: Gson = mockk() + val inAppShowFailures = listOf( + InAppShowFailure( + inAppId = inAppId, + failureReason = FailureReason.UNKNOWN_ERROR, + errorDetails = null, + dateTimeUtc = "2024-02-10T00:00:00Z" + ) + ) + every { + gson.toJson(any(), object : TypeToken() {}.type) + } throws RuntimeException("Serialization error") + inAppSerializationManager = InAppSerializationManagerImpl(gson) + + val actualJson = inAppSerializationManager.serializeToInAppShowFailuresString(inAppShowFailures) + + assertEquals("", actualJson) + } } diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/SessionStorageManagerTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/SessionStorageManagerTest.kt index 458239887..b9d167451 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/SessionStorageManagerTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/SessionStorageManagerTest.kt @@ -4,6 +4,7 @@ import cloud.mindbox.mobile_sdk.inapp.domain.models.CustomerSegmentationFetchSta import cloud.mindbox.mobile_sdk.inapp.domain.models.GeoFetchStatus import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppShowLimitsSettings import cloud.mindbox.mobile_sdk.inapp.domain.models.ProductSegmentationFetchStatus +import cloud.mindbox.mobile_sdk.inapp.domain.models.TargetingErrorKey import cloud.mindbox.mobile_sdk.models.Milliseconds import cloud.mindbox.mobile_sdk.utils.TimeProvider import io.mockk.every @@ -117,6 +118,9 @@ class SessionStorageManagerTest { configFetchingError = true sessionTime = 1000L.milliseconds inAppShowLimitsSettings = InAppShowLimitsSettings(maxInappsPerSession = 20, maxInappsPerDay = 20, minIntervalBetweenShows = Milliseconds(100)) + lastTargetingErrors[TargetingErrorKey.CustomerSegmentation] = "error in customer segment" + lastTargetingErrors[TargetingErrorKey.Geo] = "error in geo" + lastTargetingErrors[TargetingErrorKey.ProductSegmentation(Pair("product", "45"))] = "error in product segment" } sessionStorageManager.clearSessionData() @@ -134,6 +138,7 @@ class SessionStorageManagerTest { assertFalse(sessionStorageManager.configFetchingError) assertEquals(0L, sessionStorageManager.sessionTime.inWholeMilliseconds) assertEquals(InAppShowLimitsSettings(), sessionStorageManager.inAppShowLimitsSettings) + assertTrue(sessionStorageManager.lastTargetingErrors.isEmpty()) } @Test diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/serialization/MobileConfigSerializationManagerTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/serialization/MobileConfigSerializationManagerTest.kt index 3f8ffc728..72bf9fc91 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/serialization/MobileConfigSerializationManagerTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/serialization/MobileConfigSerializationManagerTest.kt @@ -1,6 +1,8 @@ package cloud.mindbox.mobile_sdk.inapp.data.managers.serialization import cloud.mindbox.mobile_sdk.di.modules.DataModule +import cloud.mindbox.mobile_sdk.inapp.data.dto.BackgroundDto +import cloud.mindbox.mobile_sdk.inapp.data.dto.PayloadDto import cloud.mindbox.mobile_sdk.inapp.data.managers.MobileConfigSerializationManagerImpl import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.MobileConfigSerializationManager import cloud.mindbox.mobile_sdk.models.InAppStub @@ -16,6 +18,8 @@ import io.mockk.impl.annotations.MockK import io.mockk.junit4.MockKRule import io.mockk.mockk import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull import org.junit.Before import org.junit.Rule @@ -480,6 +484,100 @@ internal class MobileConfigSerializationManagerTest { assertEquals(expectedResult, actualResult) } + @Test + fun `deserialize to modal window inApp form dto with webview layer success`() { + val baseUrl = "https://inapp.local/popup" + val contentUrl = "https://inapp-dev.html" + val formId = "73379" + val webViewLayerDto = BackgroundDto.LayerDto.WebViewLayerDto( + baseUrl = baseUrl, + contentUrl = contentUrl, + type = "webview", + params = mapOf("formId" to formId) + ) + val expectedResult = InAppStub.getFormDto().copy( + variants = listOf( + InAppStub.getModalWindowDto().copy( + type = "modal", + content = InAppStub.getModalWindowContentDto().copy( + background = InAppStub.getBackgroundDto().copy( + layers = listOf(webViewLayerDto) + ), + elements = null + ) + ) + ) + ) + val actualResult = mobileConfigSerializationManager.deserializeToInAppFormDto(JsonObject().apply { + add("variants", JsonArray().apply { + val variantObject = JsonObject().apply { + addProperty("${"$"}type", "modal") + add("content", JsonObject().apply { + add("background", JsonObject().apply { + add("layers", JsonArray().apply { + val webViewLayerObject = JsonObject().apply { + addProperty("${"$"}type", "webview") + addProperty("baseUrl", baseUrl) + addProperty("contentUrl", contentUrl) + add("params", JsonObject().apply { + addProperty("formId", formId) + }) + } + add(webViewLayerObject) + }) + }) + add("elements", com.google.gson.JsonNull.INSTANCE) + }) + } + add(variantObject) + }) + }) + assertEquals(expectedResult, actualResult) + } + + @Test + fun `deserialize webview layer params converts all values to string`() { + val baseUrl = "https://inapp.local/popup" + val contentUrl = "https://inapp-dev.html" + val actualResult = mobileConfigSerializationManager.deserializeToInAppFormDto(JsonObject().apply { + add("variants", JsonArray().apply { + val variantObject = JsonObject().apply { + addProperty("${"$"}type", "modal") + add("content", JsonObject().apply { + add("background", JsonObject().apply { + add("layers", JsonArray().apply { + val webViewLayerObject = JsonObject().apply { + addProperty("${"$"}type", "webview") + addProperty("baseUrl", baseUrl) + addProperty("contentUrl", contentUrl) + add("params", JsonObject().apply { + addProperty("formId", "73379") + addProperty("validKey", "validValue") + addProperty("numberKey", 123) + add("objectKey", JsonObject().apply { addProperty("nested", "value") }) + add("nullKey", com.google.gson.JsonNull.INSTANCE) + }) + } + add(webViewLayerObject) + }) + }) + add("elements", JsonArray()) + }) + } + add(variantObject) + }) + }) + val layers = actualResult?.variants?.firstOrNull() + ?.let { it as? PayloadDto.ModalWindowDto }?.content?.background?.layers + val webViewLayer = layers?.firstOrNull() as? BackgroundDto.LayerDto.WebViewLayerDto + assertNotNull(webViewLayer) + assertEquals("73379", webViewLayer?.params!!["formId"]) + assertEquals("validValue", webViewLayer.params["validKey"]) + assertEquals("123", webViewLayer.params["numberKey"]) + assertEquals("{\"nested\":\"value\"}", webViewLayer.params["objectKey"]) + assertFalse(webViewLayer.params.containsKey("nullKey")) + } + @Test fun `deserialize to inApp formDto invalid json object`() { assertNull(mobileConfigSerializationManager.deserializeToInAppFormDto(JsonObject())) @@ -564,4 +662,150 @@ internal class MobileConfigSerializationManagerTest { }) })) } + + @Test + fun `deserialize to config dto blank with tags success`() { + val successJson = gson.toJson(JsonObject().apply { + add("inapps", JsonArray().apply { + add(JsonObject().apply { + addProperty("id", "040810aa-d135-49f4-8916-7e68dcc61c71") + add("sdkVersion", JsonObject().apply { + addProperty("min", 1) + add("max", com.google.gson.JsonNull.INSTANCE) + }) + add("targeting", JsonObject().apply { + addProperty("${"$"}type", "true") + }) + add("tags", JsonObject().apply { + addProperty("layer", "webView") + addProperty("type", "onboarding") + }) + add("form", JsonObject().apply { + add("variants", JsonArray()) + }) + }) + }) + }) + val expectedResult = InAppConfigStub.getConfigResponseBlank().copy( + inApps = listOf( + InAppStub.getInAppDtoBlank().copy( + id = "040810aa-d135-49f4-8916-7e68dcc61c71", + sdkVersion = InAppStub.getSdkVersion().copy(minVersion = 1, maxVersion = null), + targeting = JsonObject().apply { addProperty("${'$'}type", "true") }, + form = JsonObject().apply { add("variants", JsonArray()) }, + tags = mapOf("layer" to "webView", "type" to "onboarding") + ) + ) + ) + val actualResult = mobileConfigSerializationManager.deserializeToConfigDtoBlank(successJson) + assertEquals(expectedResult, actualResult) + } + + @Test + fun `deserialize to config dto blank without tags field success`() { + val successJson = gson.toJson(JsonObject().apply { + add("inapps", JsonArray().apply { + add(JsonObject().apply { + addProperty("id", "040810aa-d135-49f4-8916-7e68dcc61c71") + add("sdkVersion", JsonObject().apply { + addProperty("min", 1) + add("max", com.google.gson.JsonNull.INSTANCE) + }) + add("targeting", JsonObject().apply { + addProperty("${"$"}type", "true") + }) + add("form", JsonObject().apply { + add("variants", JsonArray()) + }) + }) + }) + }) + val expectedResult = InAppConfigStub.getConfigResponseBlank().copy( + inApps = listOf( + InAppStub.getInAppDtoBlank().copy( + id = "040810aa-d135-49f4-8916-7e68dcc61c71", + sdkVersion = InAppStub.getSdkVersion().copy(minVersion = 1, maxVersion = null), + targeting = JsonObject().apply { addProperty("${'$'}type", "true") }, + form = JsonObject().apply { add("variants", JsonArray()) }, + tags = null + ) + ) + ) + val actualResult = mobileConfigSerializationManager.deserializeToConfigDtoBlank(successJson) + assertEquals(expectedResult, actualResult) + } + + @Test + fun `deserialize to config dto blank with empty tags success`() { + val successJson = gson.toJson(JsonObject().apply { + add("inapps", JsonArray().apply { + add(JsonObject().apply { + addProperty("id", "040810aa-d135-49f4-8916-7e68dcc61c71") + add("sdkVersion", JsonObject().apply { + addProperty("min", 1) + add("max", com.google.gson.JsonNull.INSTANCE) + }) + add("targeting", JsonObject().apply { + addProperty("${"$"}type", "true") + }) + add("tags", JsonObject()) + add("form", JsonObject().apply { + add("variants", JsonArray()) + }) + }) + }) + }) + val expectedResult = InAppConfigStub.getConfigResponseBlank().copy( + inApps = listOf( + InAppStub.getInAppDtoBlank().copy( + id = "040810aa-d135-49f4-8916-7e68dcc61c71", + sdkVersion = InAppStub.getSdkVersion().copy(minVersion = 1, maxVersion = null), + targeting = JsonObject().apply { addProperty("${'$'}type", "true") }, + form = JsonObject().apply { add("variants", JsonArray()) }, + tags = emptyMap() + ) + ) + ) + val actualResult = mobileConfigSerializationManager.deserializeToConfigDtoBlank(successJson) + assertEquals(expectedResult, actualResult) + } + + @Test + fun `deserialize to config dto blank with non-string tag values skips them success`() { + val successJson = gson.toJson(JsonObject().apply { + add("inapps", JsonArray().apply { + add(JsonObject().apply { + addProperty("id", "040810aa-d135-49f4-8916-7e68dcc61c71") + add("sdkVersion", JsonObject().apply { + addProperty("min", 1) + add("max", com.google.gson.JsonNull.INSTANCE) + }) + add("targeting", JsonObject().apply { + addProperty("${"$"}type", "true") + }) + add("tags", JsonObject().apply { + addProperty("layer", "webView") + addProperty("count", 42) + addProperty("flag", true) + }) + add("form", JsonObject().apply { + add("variants", JsonArray()) + }) + }) + }) + }) + val expectedResult = InAppConfigStub.getConfigResponseBlank().copy( + inApps = listOf( + InAppStub.getInAppDtoBlank().copy( + id = "040810aa-d135-49f4-8916-7e68dcc61c71", + sdkVersion = InAppStub.getSdkVersion().copy(minVersion = 1, maxVersion = null), + targeting = JsonObject().apply { addProperty("${'$'}type", "true") }, + form = JsonObject().apply { add("variants", JsonArray()) }, + tags = mapOf("layer" to "webView") + ) + ) + ) + val actualResult = mobileConfigSerializationManager.deserializeToConfigDtoBlank(successJson) + assertEquals(expectedResult, actualResult) + } } diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/serialization/SettingsMobileConfigSerializationManagerTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/serialization/SettingsMobileConfigSerializationManagerTest.kt index 9e4623b30..48bb9f388 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/serialization/SettingsMobileConfigSerializationManagerTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/serialization/SettingsMobileConfigSerializationManagerTest.kt @@ -4,6 +4,7 @@ import android.app.Application import cloud.mindbox.mobile_sdk.di.MindboxDI import cloud.mindbox.mobile_sdk.di.mindboxInject import cloud.mindbox.mobile_sdk.inapp.data.managers.MobileConfigSerializationManagerImpl +import cloud.mindbox.mobile_sdk.inapp.data.managers.SEND_INAPP_SHOW_ERROR_FEATURE import cloud.mindbox.mobile_sdk.models.operation.response.ABTestDto import cloud.mindbox.mobile_sdk.models.operation.response.SdkVersion import io.mockk.every @@ -100,6 +101,7 @@ class SettingsMobileConfigSerializationManagerTest { assertNotNull(config.settings.ttl?.inApps) assertNotNull(config.settings.slidingExpiration?.config) assertNotNull(config.settings.slidingExpiration?.pushTokenKeepalive) + assertNotNull(config.settings.featureToggles) assertNotNull(config.abtests) assertEquals(2, config.abtests!!.size) @@ -126,6 +128,9 @@ class SettingsMobileConfigSerializationManagerTest { assertNotNull(config.inappSettings?.maxInappsPerDay) assertNotNull(config.inappSettings?.maxInappsPerSession) assertNotNull(config.inappSettings?.minIntervalBetweenShows) + + assertNotNull(config.featureToggles) + assertEquals(true, config.featureToggles?.toggles?.get(SEND_INAPP_SHOW_ERROR_FEATURE)) } // MARK: - Operations @@ -633,4 +638,93 @@ class SettingsMobileConfigSerializationManagerTest { assertNull("maxInappsPerDay must be `null` if the value is not a number", config.inappSettings?.maxInappsPerDay) assertNull("minIntervalBetweenShows must be `null` if the value is not a string", config.inappSettings?.minIntervalBetweenShows) } + + // MARK: - FeatureToggles + + @Test + fun settings_config_withFeatureToggles_shouldParseSuccessfully() { + val json = getJson("ConfigParsing/Settings/FeatureTogglesConfig.json") + val config = manager.deserializeSettings(json)!! + + assertNotNull("FeatureToggles must be successfully parsed", config.featureToggles) + assertEquals(true, config.featureToggles?.toggles?.get(SEND_INAPP_SHOW_ERROR_FEATURE)) + } + + @Test + fun settings_config_withFeatureTogglesError_shouldSetFeatureTogglesToNull() { + val json = getJson("ConfigParsing/Settings/FeatureTogglesErrors/FeatureTogglesError.json") + val config = manager.deserializeSettings(json)!! + + assertNotNull("Operations must be successfully parsed", config.operations) + assertNotNull(config.operations?.get("viewProduct")) + assertNotNull(config.operations?.get("viewCategory")) + assertNotNull(config.operations?.get("setCart")) + + assertNotNull("TTL must be successfully parsed", config.ttl) + assertNotNull("TTL must be successfully parsed", config.ttl?.inApps) + + assertNull("FeatureToggles must be `null` if the key `featureToggles` is not found", config.featureToggles) + } + + @Test + fun settings_config_withFeatureTogglesTypeError_shouldSetFeatureTogglesToNull() { + val json = getJson("ConfigParsing/Settings/FeatureTogglesErrors/FeatureTogglesTypeError.json") + val config = manager.deserializeSettings(json)!! + + assertNotNull("Operations must be successfully parsed", config.operations) + assertNotNull(config.operations?.get("viewProduct")) + assertNotNull(config.operations?.get("viewCategory")) + assertNotNull(config.operations?.get("setCart")) + + assertNotNull("TTL must be successfully parsed", config.ttl) + assertNotNull("TTL must be successfully parsed", config.ttl?.inApps) + + assertNull( + "FeatureToggles must be `null` if the type of `featureToggles` is not an object", + config.featureToggles + ) + } + + @Test + fun settings_config_withFeatureTogglesShouldSendInAppShowErrorMissing_shouldSetValueToNull() { + val json = getJson("ConfigParsing/Settings/FeatureTogglesErrors/FeatureTogglesShouldSendInAppShowErrorMissing.json") + val config = manager.deserializeSettings(json)!! + + assertNotNull("Operations must be successfully parsed", config.operations) + assertNotNull(config.operations?.get("viewProduct")) + assertNotNull(config.operations?.get("viewCategory")) + assertNotNull(config.operations?.get("setCart")) + + assertNotNull("TTL must be successfully parsed", config.ttl) + assertNotNull("TTL must be successfully parsed", config.ttl?.inApps) + + assertNotNull("FeatureToggles must be parsed if the object exists", config.featureToggles) + assertTrue("FeatureToggles should be empty if no valid values", config.featureToggles!!.toggles.isEmpty()) + } + + @Test + fun settings_config_withFeatureTogglesShouldSendInAppShowErrorTypeError_shouldSetValueToNull() { + val json = getJson("ConfigParsing/Settings/FeatureTogglesErrors/FeatureTogglesShouldSendInAppShowErrorTypeError.json") + val config = manager.deserializeSettings(json)!! + + assertNotNull("Operations must be successfully parsed", config.operations) + assertNotNull(config.operations?.get("viewProduct")) + assertNotNull(config.operations?.get("viewCategory")) + assertNotNull(config.operations?.get("setCart")) + + assertNotNull("TTL must be successfully parsed", config.ttl) + assertNotNull("TTL must be successfully parsed", config.ttl?.inApps) + + assertNotNull("FeatureToggles must be parsed if the object exists", config.featureToggles) + assertNull("shouldSendInAppShowError must be `null` if the value is not a boolean", config.featureToggles?.toggles?.get(SEND_INAPP_SHOW_ERROR_FEATURE)) + } + + @Test + fun settings_config_withFeatureTogglesFalse_shouldParseFalse() { + val json = getJson("ConfigParsing/Settings/FeatureTogglesErrors/FeatureTogglesFalse.json") + val config = manager.deserializeSettings(json)!! + + assertNotNull("FeatureToggles must be successfully parsed", config.featureToggles) + assertEquals(false, config.featureToggles?.toggles?.get("shouldSendInAppShowError")) + } } diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/mapper/InAppMapperTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/mapper/InAppMapperTest.kt index 850a0eafc..f482fdbfe 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/mapper/InAppMapperTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/mapper/InAppMapperTest.kt @@ -1,8 +1,14 @@ package cloud.mindbox.mobile_sdk.inapp.data.mapper +import cloud.mindbox.mobile_sdk.inapp.data.dto.BackgroundDto +import cloud.mindbox.mobile_sdk.inapp.data.dto.PayloadDto +import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppType +import cloud.mindbox.mobile_sdk.inapp.domain.models.Layer import cloud.mindbox.mobile_sdk.inapp.domain.models.TreeTargeting +import cloud.mindbox.mobile_sdk.models.InAppStub import cloud.mindbox.mobile_sdk.models.TimeSpan import cloud.mindbox.mobile_sdk.models.TreeTargetingDto +import cloud.mindbox.mobile_sdk.models.operation.response.FormDto import cloud.mindbox.mobile_sdk.models.operation.response.FrequencyDto import cloud.mindbox.mobile_sdk.models.operation.response.InAppConfigResponse import cloud.mindbox.mobile_sdk.models.operation.response.InAppDto @@ -48,6 +54,7 @@ class InAppMapperTest { ), ), form = null, + tags = null, ) ), monitoring = null, @@ -99,6 +106,7 @@ class InAppMapperTest { ), ), form = null, + tags = null, ) ), monitoring = null, @@ -134,6 +142,7 @@ class InAppMapperTest { ), ), form = null, + tags = null, ) ), monitoring = null, @@ -169,6 +178,7 @@ class InAppMapperTest { ), ), form = null, + tags = null, ) ), monitoring = null, @@ -204,6 +214,7 @@ class InAppMapperTest { ), ), form = null, + tags = null, ) ), monitoring = null, @@ -213,4 +224,206 @@ class InAppMapperTest { ) assertNull(result.inApps.first().delayTime) } + + @Test + fun `mapToInAppConfig maps ModalWindowDto with webview layer to InAppType WebView`() { + val mapper = InAppMapper() + val inAppId = "webview-inapp-id" + val webViewLayerDto = BackgroundDto.LayerDto.WebViewLayerDto( + baseUrl = "https://inapp.local/popup", + contentUrl = "https://inapp-dev.html", + type = BackgroundDto.LayerDto.WebViewLayerDto.WEBVIEW_TYPE_JSON_NAME, + params = mapOf("formId" to "73379") + ) + val modalWindowDto = PayloadDto.ModalWindowDto( + content = PayloadDto.ModalWindowDto.ContentDto( + background = InAppStub.getBackgroundDto().copy( + layers = listOf(webViewLayerDto) + ), + elements = null + ), + type = PayloadDto.ModalWindowDto.MODAL_JSON_NAME + ) + val result = mapper.mapToInAppConfig( + InAppConfigResponse( + inApps = listOf( + InAppDto( + id = inAppId, + isPriority = false, + delayTime = null, + frequency = FrequencyDto.FrequencyOnceDto( + type = "once", + kind = "lifetime", + ), + sdkVersion = null, + targeting = TreeTargetingDto.TrueNodeDto(type = ""), + form = FormDto(variants = listOf(modalWindowDto)), + tags = null, + ) + ), + monitoring = null, + abtests = null, + settings = null, + ) + ) + val inApp = result.inApps.first() + assertTrue(inApp.form.variants.first() is InAppType.WebView) + val webView = inApp.form.variants.first() as InAppType.WebView + assertEquals(inAppId, webView.inAppId) + assertEquals(BackgroundDto.LayerDto.WebViewLayerDto.WEBVIEW_TYPE_JSON_NAME, webView.type) + assertEquals(1, webView.layers.size) + assertTrue(webView.layers.first() is Layer.WebViewLayer) + val layer = webView.layers.first() as Layer.WebViewLayer + assertEquals("https://inapp.local/popup", layer.baseUrl) + assertEquals("https://inapp-dev.html", layer.contentUrl) + assertEquals(mapOf("formId" to "73379"), layer.params) + } + + @Test + fun `mapToInAppConfig maps ModalWindowDto with image layer to InAppType ModalWindow`() { + val mapper = InAppMapper() + val inAppId = "modal-inapp-id" + val modalWindowDto = InAppStub.getModalWindowDto().copy( + content = InAppStub.getModalWindowContentDto().copy( + background = InAppStub.getBackgroundDto().copy( + layers = listOf(InAppStub.getImageLayerDto()) + ), + elements = emptyList() + ) + ) + val result = mapper.mapToInAppConfig( + InAppConfigResponse( + inApps = listOf( + InAppDto( + id = inAppId, + isPriority = false, + delayTime = null, + frequency = FrequencyDto.FrequencyOnceDto( + type = "once", + kind = "lifetime", + ), + sdkVersion = null, + targeting = TreeTargetingDto.TrueNodeDto(type = ""), + form = FormDto(variants = listOf(modalWindowDto)), + tags = null, + ) + ), + monitoring = null, + abtests = null, + settings = null, + ) + ) + val inApp = result.inApps.first() + assertTrue(inApp.form.variants.first() is InAppType.ModalWindow) + val modalWindow = inApp.form.variants.first() as InAppType.ModalWindow + assertEquals(inAppId, modalWindow.inAppId) + assertEquals(PayloadDto.ModalWindowDto.MODAL_JSON_NAME, modalWindow.type) + assertEquals(1, modalWindow.layers.size) + assertTrue(modalWindow.layers.first() is Layer.ImageLayer) + } + + @Test + fun `mapToInAppConfig maps tags from InAppDto to InApp`() { + val mapper = InAppMapper() + val inputTags = mapOf("layer" to "webView", "type" to "onboarding") + val result = mapper.mapToInAppConfig( + InAppConfigResponse( + inApps = listOf( + InAppDto( + id = "test-id", + isPriority = false, + delayTime = null, + frequency = FrequencyDto.FrequencyOnceDto(type = "once", kind = "lifetime"), + sdkVersion = null, + targeting = TreeTargetingDto.TrueNodeDto(type = ""), + form = null, + tags = inputTags, + ) + ), + monitoring = null, + abtests = null, + settings = null, + ) + ) + assertEquals(inputTags, result.inApps.first().tags) + } + + @Test + fun `mapToInAppConfig maps null tags to null in InApp`() { + val mapper = InAppMapper() + val result = mapper.mapToInAppConfig( + InAppConfigResponse( + inApps = listOf( + InAppDto( + id = "test-id", + isPriority = false, + delayTime = null, + frequency = FrequencyDto.FrequencyOnceDto(type = "once", kind = "lifetime"), + sdkVersion = null, + targeting = TreeTargetingDto.TrueNodeDto(type = ""), + form = null, + tags = null, + ) + ), + monitoring = null, + abtests = null, + settings = null, + ) + ) + assertNull(result.inApps.first().tags) + } + + @Test + fun `mapToInAppConfig maps empty tags map to null in InApp`() { + val mapper = InAppMapper() + val result = mapper.mapToInAppConfig( + InAppConfigResponse( + inApps = listOf( + InAppDto( + id = "test-id", + isPriority = false, + delayTime = null, + frequency = FrequencyDto.FrequencyOnceDto(type = "once", kind = "lifetime"), + sdkVersion = null, + targeting = TreeTargetingDto.TrueNodeDto(type = ""), + form = null, + tags = emptyMap(), + ) + ), + monitoring = null, + abtests = null, + settings = null, + ) + ) + assertNull(result.inApps.first().tags) + } + + @Test + fun `mapToInAppDto maps tags from InAppDtoBlank to InAppDto`() { + val mapper = InAppMapper() + val inputTags = mapOf("layer" to "webView", "type" to "onboarding") + val inputDtoBlank = InAppStub.getInAppDtoBlank().copy(tags = inputTags) + val result = mapper.mapToInAppDto( + inAppDtoBlank = inputDtoBlank, + delayTime = null, + formDto = null, + frequencyDto = FrequencyDto.FrequencyOnceDto(type = "once", kind = "lifetime"), + targetingDto = null, + ) + assertEquals(inputTags, result.tags) + } + + @Test + fun `mapToInAppDto maps null tags from InAppDtoBlank to InAppDto`() { + val mapper = InAppMapper() + val inputDtoBlank = InAppStub.getInAppDtoBlank().copy(tags = null) + val result = mapper.mapToInAppDto( + inAppDtoBlank = inputDtoBlank, + delayTime = null, + formDto = null, + frequencyDto = FrequencyDto.FrequencyOnceDto(type = "once", kind = "lifetime"), + targetingDto = null, + ) + assertNull(result.tags) + } } diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppRepositoryTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppRepositoryTest.kt index b3758017a..df681930d 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppRepositoryTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppRepositoryTest.kt @@ -126,8 +126,8 @@ class InAppRepositoryTest { fun `send in app shown success`() { val testInAppId = "testInAppId" val serializedString = "serializedString" - every { inAppSerializationManager.serializeToInAppHandledString(any()) } returns serializedString - inAppRepository.sendInAppShown(testInAppId) + every { inAppSerializationManager.serializeToInAppShownActionString(any(), any(), any()) } returns serializedString + inAppRepository.sendInAppShown(testInAppId, "0:00:00:00.2250000", null) verify(exactly = 1) { MindboxEventManager.inAppShown(context, serializedString) } @@ -137,8 +137,8 @@ class InAppRepositoryTest { fun `send in app shown empty string`() { val testInAppId = "testInAppId" val serializedString = "" - every { inAppSerializationManager.serializeToInAppHandledString(any()) } returns serializedString - inAppRepository.sendInAppShown(testInAppId) + every { inAppSerializationManager.serializeToInAppShownActionString(any(), any(), any()) } returns serializedString + inAppRepository.sendInAppShown(testInAppId, "0:00:00:00.2250000", null) verify(exactly = 0) { MindboxEventManager.inAppShown(context, serializedString) } @@ -148,7 +148,7 @@ class InAppRepositoryTest { fun `send in app clicked success`() { val testInAppId = "testInAppId" val serializedString = "serializedString" - every { inAppSerializationManager.serializeToInAppHandledString(any()) } returns serializedString + every { inAppSerializationManager.serializeToInAppActionString(any()) } returns serializedString inAppRepository.sendInAppClicked(testInAppId) verify(exactly = 1) { MindboxEventManager.inAppClicked(context, serializedString) @@ -159,7 +159,7 @@ class InAppRepositoryTest { fun `send in app clicked empty string`() { val testInAppId = "testInAppId" val serializedString = "" - every { inAppSerializationManager.serializeToInAppHandledString(any()) } returns serializedString + every { inAppSerializationManager.serializeToInAppActionString(any()) } returns serializedString inAppRepository.sendInAppClicked(testInAppId) verify(exactly = 0) { MindboxEventManager.inAppClicked(context, serializedString) @@ -170,10 +170,10 @@ class InAppRepositoryTest { fun `send user targeted success`() { val testInAppId = "testInAppId" val serializedString = "serializedString" - every { inAppSerializationManager.serializeToInAppHandledString(any()) } returns serializedString - inAppRepository.sendInAppClicked(testInAppId) + every { inAppSerializationManager.serializeToInAppActionString(any()) } returns serializedString + inAppRepository.sendUserTargeted(testInAppId) verify(exactly = 1) { - MindboxEventManager.inAppClicked(context, serializedString) + MindboxEventManager.sendUserTargeted(context, serializedString) } } @@ -181,10 +181,10 @@ class InAppRepositoryTest { fun `send user targeted string`() { val testInAppId = "testInAppId" val serializedString = "" - every { inAppSerializationManager.serializeToInAppHandledString(any()) } returns serializedString - inAppRepository.sendInAppClicked(testInAppId) + every { inAppSerializationManager.serializeToInAppActionString(any()) } returns serializedString + inAppRepository.sendUserTargeted(testInAppId) verify(exactly = 0) { - MindboxEventManager.inAppClicked(context, serializedString) + MindboxEventManager.sendUserTargeted(context, serializedString) } } diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppTargetingErrorRepositoryTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppTargetingErrorRepositoryTest.kt new file mode 100644 index 000000000..4af7ea6fa --- /dev/null +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppTargetingErrorRepositoryTest.kt @@ -0,0 +1,93 @@ +package cloud.mindbox.mobile_sdk.inapp.data.repositories + +import cloud.mindbox.mobile_sdk.inapp.data.managers.SessionStorageManager +import cloud.mindbox.mobile_sdk.inapp.domain.models.CustomerSegmentationError +import cloud.mindbox.mobile_sdk.inapp.domain.models.GeoError +import cloud.mindbox.mobile_sdk.inapp.domain.models.ProductSegmentationError +import cloud.mindbox.mobile_sdk.inapp.domain.models.TargetingErrorKey +import com.android.volley.NetworkResponse +import com.android.volley.VolleyError +import io.mockk.every +import io.mockk.mockk +import org.junit.Assert.assertEquals +import org.junit.Test + +internal class InAppTargetingErrorRepositoryTest { + private val sessionStorageManager = mockk(relaxUnitFun = true) + private val repository = InAppTargetingErrorRepositoryImpl(sessionStorageManager) + + @Test + fun `saveError stores customer segmentation error`() { + val errors = mutableMapOf() + val responseBody = """{"error":"customer segmentation failed"}""" + val volleyError = createVolleyError(statusCode = 500, responseBody = responseBody, networkTimeMs = 100) + val throwable = CustomerSegmentationError(volleyError) + every { sessionStorageManager.lastTargetingErrors } returns errors + repository.saveError(TargetingErrorKey.CustomerSegmentation, throwable) + val expectedDetails = "statusCode=500, networkTimeMs=${volleyError.networkTimeMs}, body=$responseBody" + assertEquals("${throwable.message}. $expectedDetails", errors[TargetingErrorKey.CustomerSegmentation]) + } + + @Test + fun `saveError stores geo error`() { + val errors = mutableMapOf() + val responseBody = """{"error":"geo failed"}""" + val volleyError = createVolleyError(statusCode = 503, responseBody = responseBody, networkTimeMs = 200) + val throwable = GeoError(volleyError) + every { sessionStorageManager.lastTargetingErrors } returns errors + repository.saveError(TargetingErrorKey.Geo, throwable) + val expectedDetails = "statusCode=503, networkTimeMs=${volleyError.networkTimeMs}, body=$responseBody" + assertEquals("${throwable.message}. $expectedDetails", errors[TargetingErrorKey.Geo]) + } + + @Test + fun `saveError stores product segmentation error`() { + val product = "website" to "ProductRandomName" + val productKey = TargetingErrorKey.ProductSegmentation(product) + val errors = mutableMapOf() + val responseBody = """{"error":"product segmentation failed"}""" + val volleyError = createVolleyError(statusCode = 504, responseBody = responseBody, networkTimeMs = 300) + val throwable = ProductSegmentationError(volleyError) + every { sessionStorageManager.lastTargetingErrors } returns errors + repository.saveError(productKey, throwable) + val expectedDetails = "statusCode=504, networkTimeMs=${volleyError.networkTimeMs}, body=$responseBody" + assertEquals("${throwable.message}. $expectedDetails", errors[productKey]) + } + + @Test + fun `getError returns saved error`() { + val product = "website" to "ProductRandomName" + val productKey = TargetingErrorKey.ProductSegmentation(product) + val errorDetails = "Product segmentation fetch failed" + every { sessionStorageManager.lastTargetingErrors[productKey] } returns errorDetails + val result = repository.getError(productKey) + assertEquals(errorDetails, result) + } + + @Test + fun `getError returns null when no error saved`() { + every { sessionStorageManager.lastTargetingErrors[TargetingErrorKey.Geo] } returns null + val result = repository.getError(TargetingErrorKey.Geo) + assertEquals(null, result) + } + + @Test + fun `clearErrors clears all stored errors`() { + val errors = mutableMapOf( + TargetingErrorKey.Geo to "Geo error", + TargetingErrorKey.CustomerSegmentation to "Customer error" + ) + every { sessionStorageManager.lastTargetingErrors } returns errors + repository.clearErrors() + assertEquals(emptyMap(), errors) + } + + private fun createVolleyError( + statusCode: Int, + responseBody: String, + networkTimeMs: Long, + ): VolleyError { + val response = NetworkResponse(statusCode, responseBody.toByteArray(), false, networkTimeMs, emptyList()) + return VolleyError(response) + } +} diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/MobileConfigRepositoryImplTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/MobileConfigRepositoryImplTest.kt index aaaa78bf2..4d73a19fd 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/MobileConfigRepositoryImplTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/MobileConfigRepositoryImplTest.kt @@ -97,7 +97,8 @@ internal class MobileConfigRepositoryImplTest { sessionStorageManager = mockk(relaxed = true), mobileConfigSettingsManager = mockk(relaxed = true), integerPositiveValidator = mockk(relaxed = true), - inappSettingsManager = mockk(relaxed = true) + inappSettingsManager = mockk(relaxed = true), + featureToggleManager = mockk(relaxed = true) ) } } diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/validators/BridgeMessageValidatorTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/validators/BridgeMessageValidatorTest.kt new file mode 100644 index 000000000..5e50eb8f0 --- /dev/null +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/validators/BridgeMessageValidatorTest.kt @@ -0,0 +1,87 @@ +package cloud.mindbox.mobile_sdk.inapp.data.validators + +import cloud.mindbox.mobile_sdk.annotations.InternalMindboxApi +import cloud.mindbox.mobile_sdk.inapp.presentation.view.BridgeMessage +import cloud.mindbox.mobile_sdk.inapp.presentation.view.WebViewAction +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +@OptIn(InternalMindboxApi::class) +class BridgeMessageValidatorTest { + private val validator: BridgeMessageValidator = BridgeMessageValidator() + + @Test + fun `isValid returns false for null message`() { + val actualResult: Boolean = validator.isValid(null) + assertFalse(actualResult) + } + + @Test + fun `isValid returns false for blank id`() { + val message: BridgeMessage.Request = createRequest(id = " ") + val actualResult: Boolean = validator.isValid(message) + assertFalse(actualResult) + } + + @Test + fun `isValid returns false for unsupported type`() { + val message: BridgeMessage.Request = createRequest(type = "unsupported") + val actualResult: Boolean = validator.isValid(message) + assertFalse(actualResult) + } + + @Test + fun `isValid returns false for non-positive timestamp`() { + val zeroTimestampMessage: BridgeMessage.Request = createRequest(timestamp = 0L) + val negativeTimestampMessage: BridgeMessage.Request = createRequest(timestamp = -1L) + val zeroTimestampResult: Boolean = validator.isValid(zeroTimestampMessage) + val negativeTimestampResult: Boolean = validator.isValid(negativeTimestampMessage) + assertFalse(zeroTimestampResult) + assertFalse(negativeTimestampResult) + } + + @Test + fun `isValid returns false for unsupported version`() { + val message: BridgeMessage.Request = createRequest(version = BridgeMessage.VERSION + 1) + val actualResult: Boolean = validator.isValid(message) + assertFalse(actualResult) + } + + @Test + fun `isValid returns true for valid request message`() { + val message: BridgeMessage.Request = createRequest() + val actualResult: Boolean = validator.isValid(message) + assertTrue(actualResult) + } + + @Test + fun `isValid returns false when reflection sets null id`() { + val message: BridgeMessage.Request = createRequest() + setFieldValue(target = message, fieldName = "id", value = null) + val actualResult: Boolean = validator.isValid(message) + assertFalse(actualResult) + } + + private fun createRequest( + id: String = "request-id", + type: String = BridgeMessage.TYPE_REQUEST, + version: Int = BridgeMessage.VERSION, + timestamp: Long = 1L, + ): BridgeMessage.Request { + return BridgeMessage.Request( + version = version, + action = WebViewAction.INIT, + payload = BridgeMessage.EMPTY_PAYLOAD, + id = id, + timestamp = timestamp, + type = type, + ) + } + + private fun setFieldValue(target: Any, fieldName: String, value: Any?) { + val field = target.javaClass.getDeclaredField(fieldName) + field.isAccessible = true + field.set(target, value) + } +} diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/validators/HapticRequestValidatorTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/validators/HapticRequestValidatorTest.kt new file mode 100644 index 000000000..c8766ad6e --- /dev/null +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/validators/HapticRequestValidatorTest.kt @@ -0,0 +1,238 @@ +package cloud.mindbox.mobile_sdk.inapp.data.validators + +import cloud.mindbox.mobile_sdk.inapp.presentation.view.HapticImpactStyle +import cloud.mindbox.mobile_sdk.inapp.presentation.view.HapticPatternEvent +import cloud.mindbox.mobile_sdk.inapp.presentation.view.HapticRequest +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class HapticRequestValidatorTest { + + private val validator: HapticRequestValidator = HapticRequestValidator() + + @Test + fun `isValid returns true for Selection`() { + val actualResult: Boolean = validator.isValid(HapticRequest.Selection) + assertTrue(actualResult) + } + + @Test + fun `isValid returns true for Impact with any style`() { + assertTrue(validator.isValid(HapticRequest.Impact(HapticImpactStyle.Light))) + assertTrue(validator.isValid(HapticRequest.Impact(HapticImpactStyle.Medium))) + assertTrue(validator.isValid(HapticRequest.Impact(HapticImpactStyle.Heavy))) + } + + @Test + fun `isValid returns false for Pattern with empty events`() { + val request: HapticRequest = HapticRequest.Pattern(events = emptyList()) + val actualResult: Boolean = validator.isValid(request) + assertFalse(actualResult) + } + + @Test + fun `isValid returns false for Pattern with more than 128 events`() { + val events: List = List(129) { validEvent(time = it * 100L) } + val request: HapticRequest = HapticRequest.Pattern(events = events) + val actualResult: Boolean = validator.isValid(request) + assertFalse(actualResult) + } + + @Test + fun `isValid returns true for Pattern with exactly 128 valid events`() { + val events: List = List(128) { validEvent(time = it * 200L) } + val request: HapticRequest = HapticRequest.Pattern(events = events) + val actualResult: Boolean = validator.isValid(request) + assertTrue(actualResult) + } + + @Test + fun `isValid returns false when event time is negative`() { + val events: List = listOf(validEvent(time = -1L)) + val request: HapticRequest = HapticRequest.Pattern(events = events) + assertFalse(validator.isValid(request)) + } + + @Test + fun `isValid returns false when event time exceeds 30000`() { + val events: List = listOf(validEvent(time = 30_001L)) + val request: HapticRequest = HapticRequest.Pattern(events = events) + assertFalse(validator.isValid(request)) + } + + @Test + fun `isValid returns true when event time is 0`() { + assertTrue(validator.isValid(HapticRequest.Pattern(listOf(validEvent(time = 0L))))) + } + + @Test + fun `isValid returns false when transient event starts at 30000 because effective duration exceeds limit`() { + val events: List = listOf(validEvent(time = 30_000L, duration = 0L)) + assertFalse(validator.isValid(HapticRequest.Pattern(events))) + } + + @Test + fun `isValid returns false when event duration is negative`() { + val events: List = listOf(validEvent(duration = -1L)) + val request: HapticRequest = HapticRequest.Pattern(events = events) + assertFalse(validator.isValid(request)) + } + + @Test + fun `isValid returns false when event duration exceeds 5000`() { + val events: List = listOf(validEvent(duration = 5_001L)) + val request: HapticRequest = HapticRequest.Pattern(events = events) + assertFalse(validator.isValid(request)) + } + + @Test + fun `isValid returns true when event duration is at boundary 0 and 5000`() { + assertTrue(validator.isValid(HapticRequest.Pattern(listOf(validEvent(duration = 0L))))) + assertTrue(validator.isValid(HapticRequest.Pattern(listOf(validEvent(duration = 5_000L))))) + } + + @Test + fun `isValid returns false when event intensity is below 0`() { + val events: List = listOf(validEvent(intensity = -0.1f)) + val request: HapticRequest = HapticRequest.Pattern(events = events) + assertFalse(validator.isValid(request)) + } + + @Test + fun `isValid returns false when event intensity exceeds 1`() { + val events: List = listOf(validEvent(intensity = 1.1f)) + val request: HapticRequest = HapticRequest.Pattern(events = events) + assertFalse(validator.isValid(request)) + } + + @Test + fun `isValid returns true when event intensity is at boundary 0 and 1`() { + assertTrue(validator.isValid(HapticRequest.Pattern(listOf(validEvent(intensity = 0f))))) + assertTrue(validator.isValid(HapticRequest.Pattern(listOf(validEvent(intensity = 1f))))) + } + + @Test + fun `isValid returns false when event sharpness is below 0`() { + val events: List = listOf(validEvent(sharpness = -0.1f)) + val request: HapticRequest = HapticRequest.Pattern(events = events) + assertFalse(validator.isValid(request)) + } + + @Test + fun `isValid returns false when event sharpness exceeds 1`() { + val events: List = listOf(validEvent(sharpness = 1.1f)) + val request: HapticRequest = HapticRequest.Pattern(events = events) + assertFalse(validator.isValid(request)) + } + + @Test + fun `isValid returns false when event time plus duration exceeds 30000`() { + val events: List = listOf( + validEvent(time = 28_000L, duration = 2_001L), + ) + val request: HapticRequest = HapticRequest.Pattern(events = events) + assertFalse(validator.isValid(request)) + } + + @Test + fun `isValid returns true when event time plus duration equals 30000`() { + val events: List = listOf( + validEvent(time = 25_000L, duration = 5_000L), + ) + val request: HapticRequest = HapticRequest.Pattern(events = events) + assertTrue(validator.isValid(request)) + } + + @Test + fun `isValid returns true for single valid pattern event`() { + val events: List = listOf(validEvent()) + val request: HapticRequest = HapticRequest.Pattern(events = events) + assertTrue(validator.isValid(request)) + } + + @Test + fun `isValid returns false when any event in pattern is invalid`() { + val events: List = listOf( + validEvent(time = 0L), + validEvent(time = 1000L, duration = 10_000L), + ) + val request: HapticRequest = HapticRequest.Pattern(events = events) + assertFalse(validator.isValid(request)) + } + + @Test + fun `isValid returns false when events overlap`() { + val events: List = listOf( + validEvent(time = 0L, duration = 200L), + validEvent(time = 100L, duration = 100L), + ) + assertFalse(validator.isValid(HapticRequest.Pattern(events))) + } + + @Test + fun `isValid returns false when events have same time`() { + val events: List = listOf( + validEvent(time = 0L, duration = 100L), + validEvent(time = 0L, duration = 100L), + ) + assertFalse(validator.isValid(HapticRequest.Pattern(events))) + } + + @Test + fun `isValid returns true when events are adjacent without overlap`() { + val events: List = listOf( + validEvent(time = 0L, duration = 100L), + validEvent(time = 100L, duration = 100L), + ) + assertTrue(validator.isValid(HapticRequest.Pattern(events))) + } + + @Test + fun `isValid returns true when events have gap between them`() { + val events: List = listOf( + validEvent(time = 0L, duration = 100L), + validEvent(time = 300L, duration = 100L), + ) + assertTrue(validator.isValid(HapticRequest.Pattern(events))) + } + + @Test + fun `isValid returns false when unsorted events overlap after sorting`() { + val events: List = listOf( + validEvent(time = 100L, duration = 100L), + validEvent(time = 0L, duration = 200L), + ) + assertFalse(validator.isValid(HapticRequest.Pattern(events))) + } + + @Test + fun `isValid returns false when transient event overlaps next event`() { + val events: List = listOf( + validEvent(time = 0L, duration = 0L), + validEvent(time = 5L, duration = 100L), + ) + assertFalse(validator.isValid(HapticRequest.Pattern(events))) + } + + @Test + fun `isValid returns true when transient event ends exactly when next event starts`() { + val events: List = listOf( + validEvent(time = 0L, duration = 0L), + validEvent(time = 10L, duration = 100L), + ) + assertTrue(validator.isValid(HapticRequest.Pattern(events))) + } + + private fun validEvent( + time: Long = 0L, + duration: Long = 100L, + intensity: Float = 1f, + sharpness: Float = 0f, + ): HapticPatternEvent = HapticPatternEvent( + time = time, + duration = duration, + intensity = intensity, + sharpness = sharpness, + ) +} diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/validators/ModalWindowValidatorTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/validators/ModalWindowValidatorTest.kt index bb367badd..e1ada3fa5 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/validators/ModalWindowValidatorTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/validators/ModalWindowValidatorTest.kt @@ -1,5 +1,7 @@ package cloud.mindbox.mobile_sdk.inapp.data.validators +import cloud.mindbox.mobile_sdk.inapp.data.dto.BackgroundDto +import cloud.mindbox.mobile_sdk.inapp.data.dto.PayloadDto import cloud.mindbox.mobile_sdk.models.InAppStub import io.mockk.every import io.mockk.impl.annotations.InjectMockKs @@ -18,6 +20,9 @@ internal class ModalWindowValidatorTest { @MockK private lateinit var imageLayerValidator: ImageLayerValidator + @MockK + private lateinit var webViewLayerValidator: WebViewLayerValidator + @MockK private lateinit var elementValidator: ModalElementValidator @@ -73,4 +78,54 @@ internal class ModalWindowValidatorTest { ) assertFalse(modalWindowValidator.isValid(modalWindowDto)) } + + @Test + fun `test isValid returns true when webview layer is valid`() { + val webViewLayerDto = BackgroundDto.LayerDto.WebViewLayerDto( + baseUrl = "https://inapp.local/popup", + contentUrl = "https://inapp-dev.html", + type = "webview", + params = mapOf("formId" to "73379") + ) + val modalWindowDto = PayloadDto.ModalWindowDto( + content = PayloadDto.ModalWindowDto.ContentDto( + background = InAppStub.getBackgroundDto().copy( + layers = listOf(webViewLayerDto) + ), + elements = null + ), + type = PayloadDto.ModalWindowDto.MODAL_JSON_NAME + ) + + every { + webViewLayerValidator.isValid(any()) + } returns true + + assertTrue(modalWindowValidator.isValid(modalWindowDto)) + } + + @Test + fun `test isValid returns false when webview layer is invalid`() { + val webViewLayerDto = BackgroundDto.LayerDto.WebViewLayerDto( + baseUrl = null, + contentUrl = "https://inapp-dev.html", + type = "webview", + params = null + ) + val modalWindowDto = PayloadDto.ModalWindowDto( + content = PayloadDto.ModalWindowDto.ContentDto( + background = InAppStub.getBackgroundDto().copy( + layers = listOf(webViewLayerDto) + ), + elements = null + ), + type = PayloadDto.ModalWindowDto.MODAL_JSON_NAME + ) + + every { + webViewLayerValidator.isValid(any()) + } returns false + + assertFalse(modalWindowValidator.isValid(modalWindowDto)) + } } diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/validators/SnackbarValidatorTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/validators/SnackbarValidatorTest.kt index d090569c7..4275f4235 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/validators/SnackbarValidatorTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/validators/SnackbarValidatorTest.kt @@ -1,5 +1,6 @@ package cloud.mindbox.mobile_sdk.inapp.data.validators +import cloud.mindbox.mobile_sdk.inapp.data.dto.BackgroundDto import cloud.mindbox.mobile_sdk.inapp.data.dto.PayloadDto import cloud.mindbox.mobile_sdk.models.InAppStub import cloud.mindbox.mobile_sdk.models.PayloadDtoStub @@ -296,6 +297,48 @@ internal class SnackbarValidatorTest { assertFalse(rez) } + @Test + fun `validate snackbar returns false when layer is webview`() { + val webViewLayerDto = BackgroundDto.LayerDto.WebViewLayerDto( + baseUrl = "https://inapp.local/popup", + contentUrl = "https://api.example.com/inapp.html", + type = "webview", + params = mapOf("formId" to "73379") + ) + val testItem = InAppStub.getSnackbarDto().copy( + type = PayloadDto.SnackbarDto.SNACKBAR_JSON_NAME, + content = InAppStub.getSnackbarContentDto().copy( + background = InAppStub.getBackgroundDto().copy( + layers = listOf(webViewLayerDto) + ), + elements = listOf( + InAppStub.getCloseButtonElementDto().copy( + color = null, + lineWidth = null, + position = null, + size = null, + type = null + ) + ), + position = PayloadDtoStub.getSnackbarPositionDto().copy( + gravity = PayloadDtoStub.getSnackbarGravityDto().copy( + horizontal = null, + vertical = null + ), + margin = PayloadDtoStub.getSnackbarMarginDto().copy( + bottom = 1.0, + kind = "dp", + left = 1.0, + right = 1.0, + top = 1.0 + ) + ) + ) + ) + val rez = snackbarValidator.isValid(testItem) + assertFalse(rez) + } + @Test fun `validate snackbar success`() { val testItem = InAppStub.getSnackbarDto().copy( diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/validators/WebViewLayerValidatorTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/validators/WebViewLayerValidatorTest.kt new file mode 100644 index 000000000..445c82ce7 --- /dev/null +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/validators/WebViewLayerValidatorTest.kt @@ -0,0 +1,93 @@ +package cloud.mindbox.mobile_sdk.inapp.data.validators + +import cloud.mindbox.mobile_sdk.inapp.data.dto.BackgroundDto +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +internal class WebViewLayerValidatorTest { + + private val webViewLayerValidator = WebViewLayerValidator() + + @Test + fun `isValid returns true for valid WebViewLayerDto`() { + val webViewLayerDto = BackgroundDto.LayerDto.WebViewLayerDto( + baseUrl = "https://inapp.local/popup", + contentUrl = "https://inapp-dev.html", + type = "webview", + params = mapOf("formId" to "73379") + ) + assertTrue(webViewLayerValidator.isValid(webViewLayerDto)) + } + + @Test + fun `isValid returns true for valid WebViewLayerDto with empty params`() { + val webViewLayerDto = BackgroundDto.LayerDto.WebViewLayerDto( + baseUrl = "https://inapp.local/popup", + contentUrl = "https://inapp-dev.html", + type = "webview", + params = null + ) + assertTrue(webViewLayerValidator.isValid(webViewLayerDto)) + } + + @Test + fun `isValid returns false when baseUrl is null`() { + val webViewLayerDto = BackgroundDto.LayerDto.WebViewLayerDto( + baseUrl = null, + contentUrl = "https://inapp-dev.html", + type = "webview", + params = null + ) + assertFalse(webViewLayerValidator.isValid(webViewLayerDto)) + } + + @Test + fun `isValid returns false when baseUrl is blank`() { + val webViewLayerDto = BackgroundDto.LayerDto.WebViewLayerDto( + baseUrl = " ", + contentUrl = "https://inapp-dev.html", + type = "webview", + params = null + ) + assertFalse(webViewLayerValidator.isValid(webViewLayerDto)) + } + + @Test + fun `isValid returns false when contentUrl is null`() { + val webViewLayerDto = BackgroundDto.LayerDto.WebViewLayerDto( + baseUrl = "https://inapp.local/popup", + contentUrl = null, + type = "webview", + params = null + ) + assertFalse(webViewLayerValidator.isValid(webViewLayerDto)) + } + + @Test + fun `isValid returns false when type is not webview`() { + val webViewLayerDto = BackgroundDto.LayerDto.WebViewLayerDto( + baseUrl = "https://inapp.local/popup", + contentUrl = "https://inapp-dev.html", + type = "image", + params = null + ) + assertFalse(webViewLayerValidator.isValid(webViewLayerDto)) + } + + @Test + fun `isValid returns false when item is null`() { + assertFalse(webViewLayerValidator.isValid(null)) + } + + @Test + fun `isValid returns false when contentUrl is blank`() { + val webViewLayerDto = BackgroundDto.LayerDto.WebViewLayerDto( + baseUrl = "https://inapp.local/popup", + contentUrl = " ", + type = "webview", + params = null + ) + assertFalse(webViewLayerValidator.isValid(webViewLayerDto)) + } +} diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppInteractorImplTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppInteractorImplTest.kt index a3f15c989..6f7dd52de 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppInteractorImplTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppInteractorImplTest.kt @@ -2,16 +2,19 @@ package cloud.mindbox.mobile_sdk.inapp.domain import app.cash.turbine.test import cloud.mindbox.mobile_sdk.abtests.InAppABTestLogic +import cloud.mindbox.mobile_sdk.inapp.data.managers.SessionStorageManager import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.InAppContentFetcher import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.checkers.Checker import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.interactors.InAppInteractor import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.InAppEventManager +import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.InAppFailureTracker import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.InAppFilteringManager import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.InAppFrequencyManager import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.InAppProcessingManager import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.repositories.InAppGeoRepository import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.repositories.InAppRepository import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.repositories.InAppSegmentationRepository +import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.repositories.InAppTargetingErrorRepository import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.repositories.MobileConfigRepository import cloud.mindbox.mobile_sdk.models.InAppEventType import cloud.mindbox.mobile_sdk.models.InAppStub @@ -65,7 +68,7 @@ class InAppInteractorImplTest { @MockK private lateinit var minIntervalBetweenShowsLimitChecker: Checker - @MockK + @RelaxedMockK private lateinit var timeProvider: TimeProvider @RelaxedMockK @@ -74,11 +77,20 @@ class InAppInteractorImplTest { @RelaxedMockK private lateinit var inAppSegmentationRepository: InAppSegmentationRepository + @RelaxedMockK + private lateinit var inAppTargetingErrorRepository: InAppTargetingErrorRepository + + @RelaxedMockK + private lateinit var sessionStorageManager: SessionStorageManager + @MockK private lateinit var inAppContentFetcher: InAppContentFetcher private lateinit var interactor: InAppInteractor + @RelaxedMockK + private lateinit var inAppFailureTracker: InAppFailureTracker + @Before fun setup() { interactor = InAppInteractorImpl( @@ -92,7 +104,8 @@ class InAppInteractorImplTest { maxInappsPerSessionLimitChecker, maxInappsPerDayLimitChecker, minIntervalBetweenShowsLimitChecker, - timeProvider + timeProvider, + sessionStorageManager ) coEvery { mobileConfigRepository.getInAppsSection() } returns emptyList() @@ -109,7 +122,7 @@ class InAppInteractorImplTest { isPriority = false, targeting = InAppStub.getTargetingTrueNode().copy("true"), form = InAppStub.getInApp().form.copy( - listOf( + variants = listOf( InAppStub.getModalWindow().copy( inAppId = "nonPriorityInapp1" ) @@ -121,7 +134,7 @@ class InAppInteractorImplTest { isPriority = true, targeting = InAppStub.getTargetingTrueNode().copy("true"), form = InAppStub.getInApp().form.copy( - listOf( + variants = listOf( InAppStub.getModalWindow().copy( inAppId = "priorityInapp" ) @@ -134,7 +147,7 @@ class InAppInteractorImplTest { isPriority = true, targeting = InAppStub.getTargetingTrueNode().copy("true"), form = InAppStub.getInApp().form.copy( - listOf( + variants = listOf( InAppStub.getModalWindow().copy( inAppId = "priorityInapp2" ) @@ -146,7 +159,7 @@ class InAppInteractorImplTest { isPriority = false, targeting = InAppStub.getTargetingTrueNode().copy("true"), form = InAppStub.getInApp().form.copy( - listOf( + variants = listOf( InAppStub.getModalWindow().copy( inAppId = "nonPriorityInApp2" ) @@ -162,8 +175,10 @@ class InAppInteractorImplTest { val realProcessingManager = InAppProcessingManagerImpl( inAppGeoRepository, inAppSegmentationRepository, + inAppTargetingErrorRepository, inAppContentFetcher, - inAppRepository + inAppRepository, + inAppFailureTracker ) interactor = InAppInteractorImpl( @@ -177,7 +192,8 @@ class InAppInteractorImplTest { maxInappsPerSessionLimitChecker, maxInappsPerDayLimitChecker, minIntervalBetweenShowsLimitChecker, - timeProvider + timeProvider, + sessionStorageManager ) coEvery { mobileConfigRepository.getInAppsSection() } returns inAppsFromConfig @@ -197,19 +213,19 @@ class InAppInteractorImplTest { interactor.processEventAndConfig().test { eventFlow.emit(InAppEventType.AppStartup) val firstItem = awaitItem() - assertEquals(priorityInApp, firstItem) + assertEquals(priorityInApp, firstItem.first) eventFlow.emit(InAppEventType.AppStartup) val secondItem = awaitItem() - assertEquals(priorityInAppTwo, secondItem) + assertEquals(priorityInAppTwo, secondItem.first) eventFlow.emit(InAppEventType.AppStartup) val thirdItem = awaitItem() - assertEquals(nonPriorityInApp, thirdItem) + assertEquals(nonPriorityInApp, thirdItem.first) eventFlow.emit(InAppEventType.AppStartup) val fourthItem = awaitItem() - assertEquals(nonPriorityInAppTwo, fourthItem) + assertEquals(nonPriorityInAppTwo, fourthItem.first) cancelAndIgnoreRemainingEvents() } diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppProcessingManagerTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppProcessingManagerTest.kt index b68df4835..03407e358 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppProcessingManagerTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppProcessingManagerTest.kt @@ -6,15 +6,20 @@ import cloud.mindbox.mobile_sdk.inapp.data.managers.SessionStorageManager import cloud.mindbox.mobile_sdk.inapp.data.mapper.InAppMapper import cloud.mindbox.mobile_sdk.inapp.data.repositories.InAppGeoRepositoryImpl import cloud.mindbox.mobile_sdk.inapp.data.repositories.InAppSegmentationRepositoryImpl +import cloud.mindbox.mobile_sdk.inapp.data.repositories.InAppTargetingErrorRepositoryImpl import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.InAppContentFetcher import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.GeoSerializationManager +import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.InAppFailureTracker import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.repositories.InAppGeoRepository import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.repositories.InAppRepository import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.repositories.InAppSegmentationRepository +import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.repositories.InAppTargetingErrorRepository import cloud.mindbox.mobile_sdk.inapp.domain.models.* import cloud.mindbox.mobile_sdk.managers.GatewayManager import cloud.mindbox.mobile_sdk.models.* +import cloud.mindbox.mobile_sdk.models.operation.request.FailureReason import cloud.mindbox.mobile_sdk.utils.TimeProvider +import com.android.volley.NetworkResponse import com.android.volley.VolleyError import com.google.gson.Gson import io.mockk.* @@ -79,6 +84,9 @@ internal class InAppProcessingManagerTest { private val inAppMapper: InAppMapper = mockk(relaxed = true) private val geoSerializationManager: GeoSerializationManager = mockk(relaxed = true) private val gatewayManager: GatewayManager = mockk(relaxed = true) + private val inAppFailureTracker: InAppFailureTracker = mockk(relaxed = true) + private val inAppTargetingErrorRepository: InAppTargetingErrorRepository = + spyk(InAppTargetingErrorRepositoryImpl(sessionStorageManager)) private val inAppGeoRepositoryTestImpl: InAppGeoRepositoryImpl = spyk( @@ -102,14 +110,18 @@ internal class InAppProcessingManagerTest { private fun setDIModule( geoRepository: InAppGeoRepository, - segmentationRepository: InAppSegmentationRepository + segmentationRepository: InAppSegmentationRepository, + targetingErrorRepository: InAppTargetingErrorRepository = inAppTargetingErrorRepository ) { - every { MindboxDI.appModule } returns mockk { - every { inAppGeoRepository } returns geoRepository - every { inAppSegmentationRepository } returns segmentationRepository - every { inAppRepository } returns mockInAppRepository - every { gson } returns Gson() - } + val appModuleMock = mockk(relaxed = true) + every { appModuleMock.inAppGeoRepository } returns geoRepository + every { appModuleMock.inAppSegmentationRepository } returns segmentationRepository + every { appModuleMock.inAppTargetingErrorRepository } returns targetingErrorRepository + every { appModuleMock.inAppRepository } returns mockInAppRepository + every { appModuleMock.gson } returns Gson() + every { appModuleMock.sessionStorageManager } returns sessionStorageManager + every { appModuleMock.inAppProcessingManager } returns inAppProcessingManager + every { MindboxDI.appModule } returns appModuleMock } @Before @@ -122,15 +134,19 @@ internal class InAppProcessingManagerTest { private val inAppProcessingManager = InAppProcessingManagerImpl( inAppGeoRepository = mockkInAppGeoRepository, inAppSegmentationRepository = mockkInAppSegmentationRepository, + inAppTargetingErrorRepository = inAppTargetingErrorRepository, inAppContentFetcher = mockkInAppContentFetcher, - inAppRepository = mockInAppRepository + inAppRepository = mockInAppRepository, + inAppFailureTracker = inAppFailureTracker ) private val inAppProcessingManagerTestImpl = InAppProcessingManagerImpl( inAppGeoRepository = inAppGeoRepositoryTestImpl, inAppSegmentationRepository = inAppSegmentationRepositoryTestImpl, + inAppTargetingErrorRepository = inAppTargetingErrorRepository, inAppContentFetcher = mockkInAppContentFetcher, - inAppRepository = mockInAppRepository + inAppRepository = mockInAppRepository, + inAppFailureTracker = inAppFailureTracker ) private fun setupTestGeoRepositoryForErrorScenario() { @@ -203,7 +219,7 @@ internal class InAppProcessingManagerTest { id = validId, targeting = InAppStub.getTargetingTrueNode(), form = InAppStub.getInApp().form.copy( - listOf( + variants = listOf( InAppStub.getModalWindow().copy( inAppId = validId ) @@ -225,7 +241,7 @@ internal class InAppProcessingManagerTest { id = validId, targeting = InAppStub.getTargetingTrueNode(), form = InAppStub.getInApp().form.copy( - listOf( + variants = listOf( InAppStub.getModalWindow().copy( inAppId = validId ) @@ -238,6 +254,37 @@ internal class InAppProcessingManagerTest { assertEquals(expectedResult, actualResult) } + @Test + fun `choose inApp to show chooses WebView inApp when targeting matches`() = runTest { + val validId = "webview-valid-id" + val expectedResult = InAppStub.getInApp().copy( + id = validId, + targeting = InAppStub.getTargetingTrueNode(), + form = InAppStub.getInApp().form.copy( + variants = listOf(InAppStub.getWebView().copy(inAppId = validId)) + ) + ) + val actualResult = inAppProcessingManager.chooseInAppToShow( + listOf( + InAppStub.getInApp().copy( + id = "123", + targeting = InAppStub.getTargetingRegionNode().copy( + type = "", kind = Kind.POSITIVE, ids = listOf("otherRegionId") + ) + ), + InAppStub.getInApp().copy( + id = validId, + targeting = InAppStub.getTargetingTrueNode(), + form = InAppStub.getInApp().form.copy( + variants = listOf(InAppStub.getWebView().copy(inAppId = validId)) + ) + ), + ), + event + ) + assertEquals(expectedResult, actualResult) + } + @Test fun `choose inApp to show has no choosable inApps`() = runTest { assertNull( @@ -314,7 +361,7 @@ internal class InAppProcessingManagerTest { id = validId, targeting = InAppStub.getTargetingTrueNode(), form = InAppStub.getInApp().form.copy( - listOf( + variants = listOf( InAppStub.getModalWindow().copy( inAppId = validId ) @@ -332,7 +379,7 @@ internal class InAppProcessingManagerTest { id = validId, targeting = InAppStub.getTargetingTrueNode(), form = InAppStub.getInApp().form.copy( - listOf( + variants = listOf( InAppStub.getModalWindow().copy( inAppId = validId ) @@ -365,7 +412,7 @@ internal class InAppProcessingManagerTest { id = validId, targeting = InAppStub.getTargetingTrueNode(), form = InAppStub.getInApp().form.copy( - listOf( + variants = listOf( InAppStub.getModalWindow().copy( inAppId = validId ) @@ -377,10 +424,14 @@ internal class InAppProcessingManagerTest { val inAppProcessingManager = InAppProcessingManagerImpl( inAppGeoRepository = mockk { coEvery { fetchGeo() } throws GeoError(VolleyError()) + every { getGeoFetchedStatus() } returns GeoFetchStatus.GEO_FETCH_ERROR + every { setGeoStatus(any()) } just runs }, inAppSegmentationRepository = mockkInAppSegmentationRepository, + inAppTargetingErrorRepository = mockk(relaxed = true), inAppContentFetcher = mockkInAppContentFetcher, - inAppRepository = mockInAppRepository + inAppRepository = mockInAppRepository, + inAppFailureTracker = mockk(relaxed = true) ) val expectedResult = InAppStub.getInApp().copy( @@ -406,7 +457,7 @@ internal class InAppProcessingManagerTest { setupTestGeoRepositoryForErrorScenario() val testInApp = InAppStub.getInApp().copy( targeting = TreeTargeting.UnionNode( - type = TreeTargetingDto.UnionNodeDto.Companion.OR_JSON_NAME, + type = TreeTargetingDto.UnionNodeDto.OR_JSON_NAME, nodes = listOf( InAppStub.getTargetingCountryNode().copy(kind = Kind.NEGATIVE), InAppStub.getTargetingTrueNode() @@ -426,7 +477,7 @@ internal class InAppProcessingManagerTest { setupTestSegmentationRepositoryForErrorScenario() val testInApp = InAppStub.getInApp().copy( targeting = TreeTargeting.UnionNode( - type = TreeTargetingDto.UnionNodeDto.Companion.OR_JSON_NAME, + type = TreeTargetingDto.UnionNodeDto.OR_JSON_NAME, nodes = listOf( InAppStub.getTargetingSegmentNode().copy(kind = Kind.NEGATIVE), InAppStub.getTargetingTrueNode() @@ -444,7 +495,7 @@ internal class InAppProcessingManagerTest { val testInApp = InAppStub.getInApp().copy( targeting = TreeTargeting.UnionNode( - type = TreeTargetingDto.UnionNodeDto.Companion.OR_JSON_NAME, + type = TreeTargetingDto.UnionNodeDto.OR_JSON_NAME, nodes = listOf( spyk(InAppStub.getTargetingViewProductSegmentNode().copy(kind = Kind.NEGATIVE)) { coEvery { fetchTargetingInfo(any()) } throws ProductSegmentationError(VolleyError()) @@ -555,4 +606,417 @@ internal class InAppProcessingManagerTest { verify(exactly = 0) { mockInAppRepository.sendUserTargeted(any()) } } + + @Test + fun `choose inApp to show tracks product segmentation failure when ViewProductSegmentNode has error`() = runTest { + val viewProductBody = """{ + "viewProduct": { + "product": { + "ids": { + "website": "ProductRandomName" + } + } + } + }""".trimIndent() + val product = "website" to "ProductRandomName" + val viewProductEvent = InAppEventType.OrdinalEvent( + EventType.SyncOperation("viewProduct"), + viewProductBody + ) + val inAppWithProductSegId = "inAppWithProductSeg" + val validId = "validId" + val serverError = VolleyError(NetworkResponse(500, null, false, 0, emptyList())) + val mockSegmentationRepo = mockk { + every { getCustomerSegmentationFetched() } returns CustomerSegmentationFetchStatus.SEGMENTATION_FETCH_SUCCESS + every { getCustomerSegmentations() } returns listOf( + SegmentationCheckInAppStub.getCustomerSegmentation().copy( + segmentation = "segmentationEI", segment = "segmentEI" + ) + ) + coEvery { fetchCustomerSegmentations() } just runs + every { getProductSegmentationFetched(product) } returns ProductSegmentationFetchStatus.SEGMENTATION_FETCH_ERROR + coEvery { fetchProductSegmentation(product) } throws ProductSegmentationError(serverError) + every { getProductSegmentations(product) } returns emptySet() + } + val targetingErrorRepository = mockk { + every { + getError(TargetingErrorKey.ProductSegmentation(product)) + } returns "Product segmentation fetch failed. statusCode=500" + every { saveError(any(), any()) } just runs + every { clearErrors() } just runs + } + setDIModule(mockkInAppGeoRepository, mockSegmentationRepo, targetingErrorRepository) + val failureTracker = mockk(relaxed = true) + val processingManager = InAppProcessingManagerImpl( + inAppGeoRepository = mockkInAppGeoRepository, + inAppSegmentationRepository = mockSegmentationRepo, + inAppTargetingErrorRepository = targetingErrorRepository, + inAppContentFetcher = mockkInAppContentFetcher, + inAppRepository = mockInAppRepository, + inAppFailureTracker = failureTracker + ) + val testInAppList = listOf( + InAppStub.getInApp().copy( + id = inAppWithProductSegId, + targeting = InAppStub.getTargetingUnionNode().copy( + nodes = listOf( + InAppStub.viewProductSegmentNode.copy( + kind = Kind.POSITIVE, + segmentationExternalId = "segmentationExternalId", + segmentExternalId = "segmentExternalId" + ) + ) + ), + form = InAppStub.getInApp().form.copy( + listOf(InAppStub.getModalWindow().copy(inAppId = inAppWithProductSegId)) + ) + ), + InAppStub.getInApp().copy( + id = validId, + targeting = InAppStub.getTargetingTrueNode(), + form = InAppStub.getInApp().form.copy( + listOf(InAppStub.getModalWindow().copy(inAppId = validId)) + ) + ) + ) + + val result = processingManager.chooseInAppToShow(testInAppList, viewProductEvent) + + assertNotNull(result) + assertEquals(validId, result?.id) + verify(exactly = 1) { + failureTracker.collectFailure( + inAppId = inAppWithProductSegId, + failureReason = FailureReason.PRODUCT_SEGMENT_REQUEST_FAILED, + errorDetails = "Product segmentation fetch failed. statusCode=500" + ) + } + verify(exactly = 1) { failureTracker.clearFailures() } + verify(exactly = 0) { failureTracker.sendCollectedFailures() } + } + + @Test + fun `choose inApp to show geo error saves last geo error details`() = runTest { + val errorDetails = "Geo fetch failed. statusCode=500" + val geoRepo = mockk { + coEvery { fetchGeo() } throws GeoError(VolleyError()) + every { getGeoFetchedStatus() } returns GeoFetchStatus.GEO_FETCH_ERROR + every { setGeoStatus(any()) } just runs + } + val targetingErrorRepository = mockk { + every { getError(TargetingErrorKey.Geo) } returns errorDetails + every { saveError(any(), any()) } just runs + every { clearErrors() } just runs + } + setDIModule(geoRepo, mockkInAppSegmentationRepository, targetingErrorRepository) + every { geoRepo.getGeo() } returns GeoTargetingStub.getGeoTargeting().copy( + cityId = "234", regionId = "regionId", countryId = "123" + ) + val validId = "validId" + val testInAppList = listOf( + InAppStub.getInApp().copy( + id = "123", + targeting = InAppStub.getTargetingRegionNode().copy( + type = "", kind = Kind.POSITIVE, ids = listOf("otherRegionId") + ) + ), + InAppStub.getInApp().copy( + id = validId, + targeting = InAppStub.getTargetingTrueNode(), + form = InAppStub.getInApp().form.copy( + listOf(InAppStub.getModalWindow().copy(inAppId = validId)) + ) + ) + ) + val failureTracker = mockk(relaxed = true) + val processingManager = InAppProcessingManagerImpl( + inAppGeoRepository = geoRepo, + inAppSegmentationRepository = mockkInAppSegmentationRepository, + inAppTargetingErrorRepository = targetingErrorRepository, + inAppContentFetcher = mockkInAppContentFetcher, + inAppRepository = mockInAppRepository, + inAppFailureTracker = failureTracker + ) + + val result = processingManager.chooseInAppToShow(testInAppList, event) + + assertNotNull(result) + assertEquals(validId, result?.id) + verify(exactly = 1) { + failureTracker.collectFailure( + inAppId = "123", + failureReason = FailureReason.GEO_TARGETING_FAILED, + errorDetails = errorDetails + ) + } + verify(exactly = 1) { failureTracker.clearFailures() } + verify(exactly = 0) { failureTracker.sendCollectedFailures() } + } + + @Test + fun `trackTargetingErrorIfAny collects customer segmentation failure when error was saved`() = runTest { + val errorDetails = "Customer segmentation fetch failed. statusCode=500" + val segmentationRepo = mockk { + coEvery { fetchCustomerSegmentations() } throws CustomerSegmentationError(VolleyError()) + every { getCustomerSegmentationFetched() } returns CustomerSegmentationFetchStatus.SEGMENTATION_FETCH_ERROR + every { setCustomerSegmentationStatus(any()) } just runs + every { getCustomerSegmentations() } returns listOf( + SegmentationCheckInAppStub.getCustomerSegmentation().copy( + segmentation = "segmentationEI", segment = "segmentEI" + ) + ) + every { getProductSegmentationFetched(any()) } returns ProductSegmentationFetchStatus.SEGMENTATION_FETCH_SUCCESS + } + val targetingErrorRepository = mockk { + every { getError(TargetingErrorKey.CustomerSegmentation) } returns errorDetails + every { saveError(any(), any()) } just runs + every { clearErrors() } just runs + } + setDIModule(mockkInAppGeoRepository, segmentationRepo, targetingErrorRepository) + val failureTracker = mockk(relaxed = true) + val processingManager = InAppProcessingManagerImpl( + inAppGeoRepository = mockkInAppGeoRepository, + inAppSegmentationRepository = segmentationRepo, + inAppTargetingErrorRepository = targetingErrorRepository, + inAppContentFetcher = mockkInAppContentFetcher, + inAppRepository = mockInAppRepository, + inAppFailureTracker = failureTracker + ) + val testInAppList = listOf( + InAppStub.getInApp().copy( + id = "123", + targeting = InAppStub.getTargetingSegmentNode().copy( + type = "", + kind = Kind.POSITIVE, + segmentationExternalId = "segmentationEI", + segmentExternalId = "segmentEI" + ), + form = InAppStub.getInApp().form.copy( + variants = listOf(InAppStub.getModalWindow().copy(inAppId = "123")) + ) + ), + InAppStub.getInApp().copy( + id = "validId", + targeting = InAppStub.getTargetingTrueNode(), + form = InAppStub.getInApp().form.copy( + variants = listOf(InAppStub.getModalWindow().copy(inAppId = "validId")) + ) + ) + ) + + val result = processingManager.chooseInAppToShow(testInAppList, event) + + assertNotNull(result) + assertEquals("validId", result?.id) + verify(exactly = 1) { + failureTracker.collectFailure( + inAppId = "123", + failureReason = FailureReason.CUSTOMER_SEGMENT_REQUEST_FAILED, + errorDetails = errorDetails + ) + } + } + + @Test + fun `trackTargetingErrorIfAny does not collect customer segmentation failure when error was not saved`() = runTest { + val segmentationRepo = mockk { + coEvery { fetchCustomerSegmentations() } throws CustomerSegmentationError(VolleyError()) + every { getCustomerSegmentationFetched() } returns CustomerSegmentationFetchStatus.SEGMENTATION_FETCH_ERROR + every { setCustomerSegmentationStatus(any()) } just runs + every { getCustomerSegmentations() } returns listOf( + SegmentationCheckInAppStub.getCustomerSegmentation().copy( + segmentation = "segmentationEI", segment = "segmentEI" + ) + ) + every { getProductSegmentationFetched(any()) } returns ProductSegmentationFetchStatus.SEGMENTATION_FETCH_SUCCESS + } + val targetingErrorRepository = mockk { + every { getError(TargetingErrorKey.CustomerSegmentation) } returns null + every { saveError(any(), any()) } just runs + every { clearErrors() } just runs + } + setDIModule(mockkInAppGeoRepository, segmentationRepo, targetingErrorRepository) + val failureTracker = mockk(relaxed = true) + val processingManager = InAppProcessingManagerImpl( + inAppGeoRepository = mockkInAppGeoRepository, + inAppSegmentationRepository = segmentationRepo, + inAppTargetingErrorRepository = targetingErrorRepository, + inAppContentFetcher = mockkInAppContentFetcher, + inAppRepository = mockInAppRepository, + inAppFailureTracker = failureTracker + ) + val testInAppList = listOf( + InAppStub.getInApp().copy( + id = "123", + targeting = InAppStub.getTargetingSegmentNode().copy( + type = "", + kind = Kind.POSITIVE, + segmentationExternalId = "segmentationEI", + segmentExternalId = "segmentEI" + ), + form = InAppStub.getInApp().form.copy( + variants = listOf(InAppStub.getModalWindow().copy(inAppId = "123")) + ) + ), + InAppStub.getInApp().copy( + id = "validId", + targeting = InAppStub.getTargetingTrueNode(), + form = InAppStub.getInApp().form.copy( + variants = listOf(InAppStub.getModalWindow().copy(inAppId = "validId")) + ) + ) + ) + + val result = processingManager.chooseInAppToShow(testInAppList, event) + + assertNotNull(result) + assertEquals("validId", result?.id) + verify(exactly = 0) { + failureTracker.collectFailure( + inAppId = "123", + failureReason = FailureReason.CUSTOMER_SEGMENT_REQUEST_FAILED, + errorDetails = any() + ) + } + } + + @Test + fun `trackTargetingErrorIfAny does not collect geo failure when error was not saved`() = runTest { + val geoRepo = mockk { + coEvery { fetchGeo() } throws GeoError(VolleyError()) + every { getGeoFetchedStatus() } returns GeoFetchStatus.GEO_FETCH_ERROR + every { setGeoStatus(any()) } just runs + every { getGeo() } returns GeoTargetingStub.getGeoTargeting().copy( + cityId = "234", regionId = "regionId", countryId = "123" + ) + } + val targetingErrorRepository = mockk { + every { getError(TargetingErrorKey.Geo) } returns null + every { saveError(any(), any()) } just runs + every { clearErrors() } just runs + } + setDIModule(geoRepo, mockkInAppSegmentationRepository, targetingErrorRepository) + val failureTracker = mockk(relaxed = true) + val processingManager = InAppProcessingManagerImpl( + inAppGeoRepository = geoRepo, + inAppSegmentationRepository = mockkInAppSegmentationRepository, + inAppTargetingErrorRepository = targetingErrorRepository, + inAppContentFetcher = mockkInAppContentFetcher, + inAppRepository = mockInAppRepository, + inAppFailureTracker = failureTracker + ) + val testInAppList = listOf( + InAppStub.getInApp().copy( + id = "123", + targeting = InAppStub.getTargetingRegionNode().copy( + type = "", kind = Kind.POSITIVE, ids = listOf("otherRegionId") + ), + form = InAppStub.getInApp().form.copy( + listOf(InAppStub.getModalWindow().copy(inAppId = "123")) + ) + ), + InAppStub.getInApp().copy( + id = "validId", + targeting = InAppStub.getTargetingTrueNode(), + form = InAppStub.getInApp().form.copy( + listOf(InAppStub.getModalWindow().copy(inAppId = "validId")) + ) + ) + ) + + val result = processingManager.chooseInAppToShow(testInAppList, event) + + assertNotNull(result) + assertEquals("validId", result?.id) + verify(exactly = 0) { + failureTracker.collectFailure( + inAppId = "123", + failureReason = FailureReason.GEO_TARGETING_FAILED, + errorDetails = any() + ) + } + } + + @Test + fun `trackTargetingErrorIfAny does not collect product segmentation failure when error was not saved`() = runTest { + val viewProductBody = """{ + "viewProduct": { + "product": { + "ids": { + "website": "ProductRandomName" + } + } + } + }""".trimIndent() + val product = "website" to "ProductRandomName" + val viewProductEvent = InAppEventType.OrdinalEvent( + EventType.SyncOperation("viewProduct"), + viewProductBody + ) + val inAppWithProductSegId = "inAppWithProductSeg" + val validId = "validId" + val mockSegmentationRepo = mockk { + every { getCustomerSegmentationFetched() } returns CustomerSegmentationFetchStatus.SEGMENTATION_FETCH_SUCCESS + every { getCustomerSegmentations() } returns listOf( + SegmentationCheckInAppStub.getCustomerSegmentation().copy( + segmentation = "segmentationEI", segment = "segmentEI" + ) + ) + coEvery { fetchCustomerSegmentations() } just runs + every { getProductSegmentationFetched(product) } returns ProductSegmentationFetchStatus.SEGMENTATION_FETCH_ERROR + coEvery { fetchProductSegmentation(product) } throws ProductSegmentationError(VolleyError()) + every { getProductSegmentations(product) } returns emptySet() + } + val targetingErrorRepository = mockk { + every { getError(TargetingErrorKey.ProductSegmentation(product)) } returns null + every { saveError(any(), any()) } just runs + every { clearErrors() } just runs + } + setDIModule(mockkInAppGeoRepository, mockSegmentationRepo, targetingErrorRepository) + val failureTracker = mockk(relaxed = true) + val processingManager = InAppProcessingManagerImpl( + inAppGeoRepository = mockkInAppGeoRepository, + inAppSegmentationRepository = mockSegmentationRepo, + inAppTargetingErrorRepository = targetingErrorRepository, + inAppContentFetcher = mockkInAppContentFetcher, + inAppRepository = mockInAppRepository, + inAppFailureTracker = failureTracker + ) + val testInAppList = listOf( + InAppStub.getInApp().copy( + id = inAppWithProductSegId, + targeting = InAppStub.getTargetingUnionNode().copy( + nodes = listOf( + InAppStub.viewProductSegmentNode.copy( + kind = Kind.POSITIVE, + segmentationExternalId = "segmentationExternalId", + segmentExternalId = "segmentExternalId" + ) + ) + ), + form = InAppStub.getInApp().form.copy( + variants = listOf(InAppStub.getModalWindow().copy(inAppId = inAppWithProductSegId)) + ) + ), + InAppStub.getInApp().copy( + id = validId, + targeting = InAppStub.getTargetingTrueNode(), + form = InAppStub.getInApp().form.copy( + variants = listOf(InAppStub.getModalWindow().copy(inAppId = validId)) + ) + ) + ) + + val result = processingManager.chooseInAppToShow(testInAppList, viewProductEvent) + + assertNotNull(result) + assertEquals(validId, result?.id) + verify(exactly = 0) { + failureTracker.collectFailure( + inAppId = inAppWithProductSegId, + failureReason = FailureReason.PRODUCT_SEGMENT_REQUEST_FAILED, + errorDetails = any() + ) + } + } } diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/extensions/TrackingFailureExtensionTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/extensions/TrackingFailureExtensionTest.kt new file mode 100644 index 000000000..5f7dafb09 --- /dev/null +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/extensions/TrackingFailureExtensionTest.kt @@ -0,0 +1,122 @@ +package cloud.mindbox.mobile_sdk.inapp.domain.extensions + +import cloud.mindbox.mobile_sdk.inapp.domain.models.CustomerSegmentationError +import cloud.mindbox.mobile_sdk.inapp.domain.models.GeoError +import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppContentFetchingError +import cloud.mindbox.mobile_sdk.inapp.domain.models.ProductSegmentationError +import com.android.volley.NetworkResponse +import com.android.volley.NoConnectionError +import com.android.volley.TimeoutError +import com.android.volley.VolleyError +import com.bumptech.glide.load.HttpException +import com.bumptech.glide.load.engine.GlideException +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.net.ConnectException +import java.net.SocketTimeoutException +import java.net.UnknownHostException + +internal class TrackingFailureExtensionTest { + + @Test + fun `shouldTrackTargetingError returns true for 5xx server error`() { + val serverError = VolleyError(NetworkResponse(500, null, false, 0, emptyList())) + val geoError = GeoError(serverError) + assertTrue(geoError.shouldTrackTargetingError()) + } + + @Test + fun `shouldTrackTargetingError returns true for 503 server error`() { + val serverError = VolleyError(NetworkResponse(503, null, false, 0, emptyList())) + val segmentationError = CustomerSegmentationError(serverError) + assertTrue(segmentationError.shouldTrackTargetingError()) + } + + @Test + fun `shouldTrackTargetingError returns false for TimeoutError`() { + val timeoutError = TimeoutError() + val geoError = GeoError(timeoutError) + assertFalse(geoError.shouldTrackTargetingError()) + } + + @Test + fun `shouldTrackTargetingError returns false for NoConnectionError`() { + val noConnectionError = NoConnectionError() + val segmentationError = CustomerSegmentationError(noConnectionError) + assertFalse(segmentationError.shouldTrackTargetingError()) + } + + @Test + fun `shouldTrackTargetingError returns false for VolleyError with SocketTimeoutException cause`() { + val volleyError = VolleyError(SocketTimeoutException("timeout")) + val productError = ProductSegmentationError(volleyError) + assertFalse(productError.shouldTrackTargetingError()) + } + + @Test + fun `shouldTrackTargetingError returns false for 4xx client error`() { + val clientError = VolleyError(NetworkResponse(404, null, false, 0, emptyList())) + val geoError = GeoError(clientError) + assertFalse(geoError.shouldTrackTargetingError()) + } + + @Test + fun `shouldTrackTargetingError returns false when cause is not VolleyError`() { + val throwable = Exception(IllegalStateException("not volley")) + assertFalse(throwable.shouldTrackTargetingError()) + } + + @Test + fun `shouldTrackImageDownloadError returns false for GlideException with SocketTimeoutException in rootCauses`() { + val glideException = GlideException("load failed", listOf(SocketTimeoutException("timeout"))) + val inAppError = InAppContentFetchingError(glideException) + assertFalse(inAppError.shouldTrackImageDownloadError()) + } + + @Test + fun `shouldTrackImageDownloadError returns false for GlideException with HttpException and UnknownHostException`() { + val httpException = HttpException("connection failed", -1, UnknownHostException("no host")) + val glideException = GlideException("load failed", listOf(httpException)) + val inAppError = InAppContentFetchingError(glideException) + assertFalse(inAppError.shouldTrackImageDownloadError()) + } + + @Test + fun `shouldTrackImageDownloadError returns false for GlideException with HttpException and ConnectException`() { + val httpException = HttpException("connection failed", -1, ConnectException("connection refused")) + val glideException = GlideException("load failed", listOf(httpException)) + val inAppError = InAppContentFetchingError(glideException) + assertFalse(inAppError.shouldTrackImageDownloadError()) + } + + @Test + fun `shouldTrackImageDownloadError returns true for GlideException with 404 HttpException`() { + val httpException = HttpException("not found", 404) + val glideException = GlideException("load failed", listOf(httpException)) + val inAppError = InAppContentFetchingError(glideException) + assertTrue(inAppError.shouldTrackImageDownloadError()) + } + + @Test + fun `shouldTrackImageDownloadError returns true for GlideException with 500 HttpException`() { + val httpException = HttpException("server error", 500) + val glideException = GlideException("load failed", listOf(httpException)) + val inAppError = InAppContentFetchingError(glideException) + assertTrue(inAppError.shouldTrackImageDownloadError()) + } + + @Test + fun `shouldTrackImageDownloadError returns true when cause is not GlideException`() { + val throwable = Exception("generic error") + assertTrue(throwable.shouldTrackImageDownloadError()) + } + + @Test + fun `shouldTrackImageDownloadError returns false for GlideException with SocketTimeoutException as cause of rootCause`() { + val rootCause = Exception(SocketTimeoutException("timeout")) + val glideException = GlideException("load failed", listOf(rootCause)) + val inAppError = InAppContentFetchingError(glideException) + assertFalse(inAppError.shouldTrackImageDownloadError()) + } +} diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/models/TreeTargetingTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/models/TreeTargetingTest.kt index cb51ed1be..fe249737d 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/models/TreeTargetingTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/models/TreeTargetingTest.kt @@ -75,6 +75,74 @@ class TreeTargetingTest { assertTrue(InAppStub.getTargetingTrueNode().checkTargeting(mockk())) } + @Test + fun `TrueNode hasProductSegmentationNode always false`() { + assertFalse(InAppStub.getTargetingTrueNode().hasProductSegmentationNode()) + } + + @Test + fun `CountryNode hasProductSegmentationNode always false`() { + assertFalse(InAppStub.getTargetingCountryNode().hasProductSegmentationNode()) + } + + @Test + fun `CityNode hasProductSegmentationNode always false`() { + assertFalse(InAppStub.getTargetingCityNode().hasProductSegmentationNode()) + } + + @Test + fun `RegionNode hasProductSegmentationNode always false`() { + assertFalse(InAppStub.getTargetingRegionNode().hasProductSegmentationNode()) + } + + @Test + fun `SegmentNode hasProductSegmentationNode always false`() { + assertFalse(InAppStub.getTargetingSegmentNode().hasProductSegmentationNode()) + } + + @Test + fun `VisitNode hasProductSegmentationNode always false`() { + assertFalse(InAppStub.getTargetingVisitNode().hasProductSegmentationNode()) + } + + @Test + fun `PushPermissionNode hasProductSegmentationNode always false`() { + assertFalse(InAppStub.getTargetingPushPermissionNode().hasProductSegmentationNode()) + } + + @Test + fun `OperationNode hasProductSegmentationNode always false`() { + assertFalse(InAppStub.getTargetingOperationNode().hasProductSegmentationNode()) + } + + @Test + fun `IntersectionNode hasProductSegmentationNode false when no child has it`() { + val node = InAppStub.getTargetingIntersectionNode() + .copy(nodes = listOf(InAppStub.getTargetingTrueNode(), InAppStub.getTargetingCityNode())) + assertFalse(node.hasProductSegmentationNode()) + } + + @Test + fun `IntersectionNode hasProductSegmentationNode true when child has it`() { + val node = InAppStub.getTargetingIntersectionNode() + .copy(nodes = listOf(InAppStub.getTargetingTrueNode(), InAppStub.viewProductSegmentNode)) + assertTrue(node.hasProductSegmentationNode()) + } + + @Test + fun `UnionNode hasProductSegmentationNode false when no child has it`() { + val node = InAppStub.getTargetingUnionNode() + .copy(nodes = listOf(InAppStub.getTargetingTrueNode(), InAppStub.getTargetingCityNode())) + assertFalse(node.hasProductSegmentationNode()) + } + + @Test + fun `UnionNode hasProductSegmentationNode true when child has it`() { + val node = InAppStub.getTargetingUnionNode() + .copy(nodes = listOf(InAppStub.getTargetingTrueNode(), InAppStub.viewProductSegmentNode)) + assertTrue(node.hasProductSegmentationNode()) + } + @Test fun `country targeting positive success check`() { assertTrue( diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/models/ViewProductCategoryInNodeTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/models/ViewProductCategoryInNodeTest.kt index ad788e571..8ce8e4249 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/models/ViewProductCategoryInNodeTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/models/ViewProductCategoryInNodeTest.kt @@ -67,6 +67,11 @@ class ViewProductCategoryInNodeTest { assertFalse(InAppStub.viewProductCategoryInNode.hasSegmentationNode()) } + @Test + fun `hasProductSegmentationNode always false`() { + assertFalse(InAppStub.viewProductCategoryInNode.hasProductSegmentationNode()) + } + @Test fun `getOperationsSet return viewCategory`() = runTest { assertEquals( diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/models/ViewProductCategoryNodeTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/models/ViewProductCategoryNodeTest.kt index 8adc313f1..e5b25f935 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/models/ViewProductCategoryNodeTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/models/ViewProductCategoryNodeTest.kt @@ -67,6 +67,11 @@ class ViewProductCategoryNodeTest { assertFalse(InAppStub.viewProductCategoryNode.hasSegmentationNode()) } + @Test + fun `hasProductSegmentationNode always false`() { + assertFalse(InAppStub.viewProductCategoryNode.hasProductSegmentationNode()) + } + @Test fun `getOperationsSet return viewCategory`() = runTest { assertEquals( diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/models/ViewProductNodeTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/models/ViewProductNodeTest.kt index 443750f4e..e43a09aae 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/models/ViewProductNodeTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/models/ViewProductNodeTest.kt @@ -67,6 +67,11 @@ class ViewProductNodeTest { assertFalse(InAppStub.viewProductNode.hasSegmentationNode()) } + @Test + fun `hasProductSegmentationNode always false`() { + assertFalse(InAppStub.viewProductNode.hasProductSegmentationNode()) + } + @Test fun `checkTargeting after AppStartup`() = runTest { MindboxEventManager.eventFlow.resetReplayCache() diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/models/ViewProductSegmentNodeTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/models/ViewProductSegmentNodeTest.kt index 9b2dba548..ff054048c 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/models/ViewProductSegmentNodeTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/models/ViewProductSegmentNodeTest.kt @@ -2,6 +2,7 @@ package cloud.mindbox.mobile_sdk.inapp.domain.models import cloud.mindbox.mobile_sdk.di.MindboxDI import cloud.mindbox.mobile_sdk.inapp.data.managers.SessionStorageManager +import cloud.mindbox.mobile_sdk.inapp.data.repositories.InAppTargetingErrorRepositoryImpl import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.repositories.InAppSegmentationRepository import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.repositories.MobileConfigRepository import cloud.mindbox.mobile_sdk.managers.MindboxEventManager @@ -31,7 +32,8 @@ class ViewProductSegmentNodeTest { } private val mockkInAppSegmentationRepository: InAppSegmentationRepository = mockk() - private val sessionStorageManager = mockk() + private val sessionStorageManagerMock = mockk() + private val inAppTargetingErrorRepositoryMock = mockk() @get:Rule val mockkRule = MockKRule(this) @@ -47,6 +49,8 @@ class ViewProductSegmentNodeTest { every { mobileConfigRepository } returns mockkMobileConfigRepository every { inAppSegmentationRepository } returns mockkInAppSegmentationRepository every { gson } returns Gson() + every { inAppTargetingErrorRepository } returns inAppTargetingErrorRepositoryMock + every { sessionStorageManager } returns sessionStorageManagerMock } } @@ -65,6 +69,11 @@ class ViewProductSegmentNodeTest { assertFalse(InAppStub.viewProductSegmentNode.hasSegmentationNode()) } + @Test + fun `hasProductSegmentationNode always true`() { + assertTrue(InAppStub.viewProductSegmentNode.hasProductSegmentationNode()) + } + @Test fun `check targeting positive success`() = runTest { val productSegmentation = @@ -259,7 +268,7 @@ class ViewProductSegmentNodeTest { "website" to "successProduct" to ProductSegmentationFetchStatus.SEGMENTATION_FETCH_SUCCESS, "website" to "errorProduct" to ProductSegmentationFetchStatus.SEGMENTATION_FETCH_ERROR ) - every { sessionStorageManager.processedProductSegmentations } returns processedProducts + every { sessionStorageManagerMock.processedProductSegmentations } returns processedProducts every { mockkInAppSegmentationRepository.getProductSegmentationFetched("website" to "successProduct") } returns ProductSegmentationFetchStatus.SEGMENTATION_FETCH_SUCCESS every { mockkInAppSegmentationRepository.getProductSegmentationFetched("website" to "errorProduct") } returns ProductSegmentationFetchStatus.SEGMENTATION_FETCH_ERROR every { mockkInAppSegmentationRepository.getProductSegmentationFetched("website" to "newProduct") } returns ProductSegmentationFetchStatus.SEGMENTATION_NOT_FETCHED diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageDelayedManagerTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageDelayedManagerTest.kt index c4b35a9bf..7946d9a24 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageDelayedManagerTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageDelayedManagerTest.kt @@ -25,10 +25,10 @@ class InAppMessageDelayedManagerTest { val inApp = InAppStub.getInApp().copy(delayTime = Milliseconds(10000)) every { timeProvider.currentTimeMillis() } answers { testDispatcher.scheduler.currentTime } - inAppMessageDelayedManager.process(inApp) + inAppMessageDelayedManager.process(inApp, Milliseconds(0L)) inAppMessageDelayedManager.inAppToShowFlow.test { advanceTimeBy(10_001) - assertEquals(inApp, awaitItem()) + assertEquals(inApp, awaitItem().first) cancelAndIgnoreRemainingEvents() } } @@ -38,13 +38,13 @@ class InAppMessageDelayedManagerTest { every { timeProvider.currentTimeMillis() } answers { testDispatcher.scheduler.currentTime } val inApp = InAppStub.getInApp().copy(delayTime = Milliseconds(10000)) - inAppMessageDelayedManager.process(inApp) + inAppMessageDelayedManager.process(inApp, Milliseconds(0L)) inAppMessageDelayedManager.inAppToShowFlow.test { advanceTimeBy(9_999) expectNoEvents() advanceTimeBy(1) - assertEquals(inApp, awaitItem()) + assertEquals(inApp, awaitItem().first) cancelAndIgnoreRemainingEvents() } } @@ -53,7 +53,7 @@ class InAppMessageDelayedManagerTest { fun `clearSession should cancel pending jobs and clear queue`() = runTest(testDispatcher.scheduler) { every { timeProvider.currentTimeMillis() } answers { testDispatcher.scheduler.currentTime } val inApp = InAppStub.getInApp().copy(delayTime = Milliseconds(10000)) - inAppMessageDelayedManager.process(inApp) + inAppMessageDelayedManager.process(inApp, Milliseconds(0L)) inAppMessageDelayedManager.clearSession() inAppMessageDelayedManager.inAppToShowFlow.test { testDispatcher.scheduler.advanceUntilIdle() @@ -67,11 +67,11 @@ class InAppMessageDelayedManagerTest { val inAppOne = InAppStub.getInApp().copy(id = "inApp1", delayTime = Milliseconds(10000)) val inAppTwo = InAppStub.getInApp().copy(id = "inApp2", delayTime = Milliseconds(5000), isPriority = true) - inAppMessageDelayedManager.process(inAppOne) - inAppMessageDelayedManager.process(inAppTwo) + inAppMessageDelayedManager.process(inAppOne, Milliseconds(0L)) + inAppMessageDelayedManager.process(inAppTwo, Milliseconds(0L)) inAppMessageDelayedManager.inAppToShowFlow.test { - assertEquals(inAppTwo, awaitItem()) + assertEquals(inAppTwo, awaitItem().first) cancelAndIgnoreRemainingEvents() } } @@ -82,11 +82,11 @@ class InAppMessageDelayedManagerTest { val inAppNonPriority = InAppStub.getInApp().copy(id = "inApp1", delayTime = Milliseconds(5000)) val inAppPriority = InAppStub.getInApp().copy(id = "inApp2", delayTime = Milliseconds(5000), isPriority = true) - inAppMessageDelayedManager.process(inAppNonPriority) - inAppMessageDelayedManager.process(inAppPriority) + inAppMessageDelayedManager.process(inAppNonPriority, Milliseconds(0L)) + inAppMessageDelayedManager.process(inAppPriority, Milliseconds(0L)) inAppMessageDelayedManager.inAppToShowFlow.test { - assertEquals(inAppPriority, awaitItem()) + assertEquals(inAppPriority, awaitItem().first) cancelAndIgnoreRemainingEvents() } } @@ -97,11 +97,11 @@ class InAppMessageDelayedManagerTest { val inAppFirst = InAppStub.getInApp().copy(id = "inApp1", delayTime = Milliseconds(5000), isPriority = true) val inAppSecond = InAppStub.getInApp().copy(id = "inApp2", delayTime = Milliseconds(5000), isPriority = true) - inAppMessageDelayedManager.process(inAppFirst) - inAppMessageDelayedManager.process(inAppSecond) + inAppMessageDelayedManager.process(inAppFirst, Milliseconds(0L)) + inAppMessageDelayedManager.process(inAppSecond, Milliseconds(0L)) inAppMessageDelayedManager.inAppToShowFlow.test { - assertEquals(inAppFirst, awaitItem()) + assertEquals(inAppFirst, awaitItem().first) cancelAndIgnoreRemainingEvents() } } @@ -113,12 +113,12 @@ class InAppMessageDelayedManagerTest { val inAppLoser1 = InAppStub.getInApp().copy(id = "loser1", delayTime = Milliseconds(5000), isPriority = false) val inAppLoser2 = InAppStub.getInApp().copy(id = "loser2", delayTime = Milliseconds(3000), isPriority = false) - inAppMessageDelayedManager.process(inAppWinner) - inAppMessageDelayedManager.process(inAppLoser1) - inAppMessageDelayedManager.process(inAppLoser2) + inAppMessageDelayedManager.process(inAppWinner, Milliseconds(0L)) + inAppMessageDelayedManager.process(inAppLoser1, Milliseconds(0L)) + inAppMessageDelayedManager.process(inAppLoser2, Milliseconds(0L)) advanceTimeBy(5000) inAppMessageDelayedManager.inAppToShowFlow.test { - assertEquals(inAppWinner, awaitItem()) + assertEquals(inAppWinner, awaitItem().first) expectNoEvents() } } diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageManagerTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageManagerTest.kt index 5d784fbeb..c0e33f37f 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageManagerTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageManagerTest.kt @@ -7,10 +7,12 @@ import cloud.mindbox.mobile_sdk.inapp.domain.models.InApp import cloud.mindbox.mobile_sdk.logger.MindboxLoggerImpl import cloud.mindbox.mobile_sdk.managers.UserVisitManager import cloud.mindbox.mobile_sdk.models.InAppStub +import cloud.mindbox.mobile_sdk.models.Milliseconds import cloud.mindbox.mobile_sdk.monitoring.domain.interfaces.MonitoringInteractor import cloud.mindbox.mobile_sdk.repository.MindboxPreferences import cloud.mindbox.mobile_sdk.sortByPriority import cloud.mindbox.mobile_sdk.utils.LoggingExceptionHandler +import cloud.mindbox.mobile_sdk.utils.SystemTimeProvider import cloud.mindbox.mobile_sdk.utils.mockLogger import cloud.mindbox.mobile_sdk.utils.mockPreferencesConfigSetter import com.android.volley.NetworkResponse @@ -55,6 +57,8 @@ internal class InAppMessageManagerTest { private val testDispatcher = StandardTestDispatcher() + private val timeProvider = mockk() + /** * sets a thread to be used as main dispatcher for running on JVM * **/ @@ -90,7 +94,8 @@ internal class InAppMessageManagerTest { monitoringRepository, sessionStorageManager, userVisitManager, - inAppMessageDelayedManager + inAppMessageDelayedManager, + timeProvider ) coEvery { inAppMessageInteractor.fetchMobileConfig() @@ -112,7 +117,8 @@ internal class InAppMessageManagerTest { monitoringRepository, sessionStorageManager, userVisitManager, - inAppMessageDelayedManager + inAppMessageDelayedManager, + timeProvider ) mockkObject(LoggingExceptionHandler) every { MindboxPreferences.inAppConfig } returns "test" @@ -132,14 +138,14 @@ internal class InAppMessageManagerTest { @Test fun `in app messages success message shown`() = runTest { - val inAppToShowFlow = MutableSharedFlow() + val inAppToShowFlow = MutableSharedFlow>() val inApp = InAppStub.getInApp() every { inAppMessageViewDisplayer.isInAppActive() } returns false every { inAppMessageInteractor.areShowAndFrequencyLimitsAllowed(any()) } returns true every { inAppMessageDelayedManager.inAppToShowFlow } returns inAppToShowFlow - every { inAppMessageDelayedManager.process(inApp) } coAnswers { + every { inAppMessageDelayedManager.process(inApp, any()) } coAnswers { this@runTest.launch { - inAppToShowFlow.emit(inApp) + inAppToShowFlow.emit(inApp to Milliseconds(0L)) } } @@ -150,28 +156,27 @@ internal class InAppMessageManagerTest { monitoringRepository, sessionStorageManager, userVisitManager, - inAppMessageDelayedManager + inAppMessageDelayedManager, + timeProvider ) coEvery { inAppMessageInteractor.processEventAndConfig() }.answers { flow { - emit( - inApp - ) + emit(inApp to Milliseconds(0L)) } } inAppMessageManager.listenEventAndInApp() advanceUntilIdle() - verify(exactly = 1) { inAppMessageDelayedManager.process(inApp) } - verify(exactly = 1) { inAppMessageViewDisplayer.tryShowInAppMessage(inApp.form.variants.first(), any()) } + verify(exactly = 1) { inAppMessageDelayedManager.process(inApp, any()) } + verify(exactly = 1) { inAppMessageViewDisplayer.tryShowInAppMessage(inApp.form.variants.first(), any(), any()) } } @Test fun `in app messages success message not shown when inApp already active`() = runTest { - val inAppToShowFlow = MutableSharedFlow() + val inAppToShowFlow = MutableSharedFlow>() val inApp = InAppStub.getInApp() every { inAppMessageInteractor.areShowAndFrequencyLimitsAllowed(any()) } returns true every { inAppMessageViewDisplayer.isInAppActive() } returns true @@ -182,7 +187,8 @@ internal class InAppMessageManagerTest { monitoringRepository, sessionStorageManager, userVisitManager, - inAppMessageDelayedManager + inAppMessageDelayedManager, + timeProvider ) coEvery { inAppMessageInteractor.listenToTargetingEvents() @@ -191,28 +197,26 @@ internal class InAppMessageManagerTest { inAppMessageInteractor.processEventAndConfig() }.answers { flow { - emit( - inApp - ) + emit(inApp to Milliseconds(0L)) } } every { inAppMessageDelayedManager.inAppToShowFlow } returns inAppToShowFlow - every { inAppMessageDelayedManager.process(inApp) } answers { + every { inAppMessageDelayedManager.process(inApp, any()) } answers { this@runTest.launch { - inAppToShowFlow.emit(inApp) + inAppToShowFlow.emit(inApp to Milliseconds(0L)) } } inAppMessageManager.listenEventAndInApp() advanceUntilIdle() - verify(exactly = 1) { inAppMessageDelayedManager.process(inApp) } + verify(exactly = 1) { inAppMessageDelayedManager.process(inApp, any()) } coVerify(exactly = 1) { inAppMessageInteractor.listenToTargetingEvents() } - verify(exactly = 0) { inAppMessageViewDisplayer.tryShowInAppMessage(inApp.form.variants.first(), any()) } + verify(exactly = 0) { inAppMessageViewDisplayer.tryShowInAppMessage(inApp.form.variants.first(), any(), any()) } } @Test fun `in app messages success message not shown when inApp frequency or limits not allowed`() = runTest { - val inAppToShowFlow = MutableSharedFlow() + val inAppToShowFlow = MutableSharedFlow>() val inApp = InAppStub.getInApp() every { inAppMessageInteractor.areShowAndFrequencyLimitsAllowed(any()) } returns false every { inAppMessageViewDisplayer.isInAppActive() } returns false @@ -223,7 +227,8 @@ internal class InAppMessageManagerTest { monitoringRepository, sessionStorageManager, userVisitManager, - inAppMessageDelayedManager + inAppMessageDelayedManager, + timeProvider ) coEvery { inAppMessageInteractor.listenToTargetingEvents() @@ -232,23 +237,21 @@ internal class InAppMessageManagerTest { inAppMessageInteractor.processEventAndConfig() }.answers { flow { - emit( - inApp - ) + emit(inApp to Milliseconds(0L)) } } every { inAppMessageDelayedManager.inAppToShowFlow } returns inAppToShowFlow - every { inAppMessageDelayedManager.process(inApp) } answers { + every { inAppMessageDelayedManager.process(inApp, any()) } answers { this@runTest.launch { - inAppToShowFlow.emit(inApp) + inAppToShowFlow.emit(inApp to Milliseconds(0L)) } } inAppMessageManager.listenEventAndInApp() advanceUntilIdle() - verify(exactly = 1) { inAppMessageDelayedManager.process(inApp) } + verify(exactly = 1) { inAppMessageDelayedManager.process(inApp, any()) } coVerify(exactly = 1) { inAppMessageInteractor.listenToTargetingEvents() } - verify(exactly = 0) { inAppMessageViewDisplayer.tryShowInAppMessage(inApp.form.variants.first(), any()) } + verify(exactly = 0) { inAppMessageViewDisplayer.tryShowInAppMessage(inApp.form.variants.first(), any(), any()) } } @Test @@ -260,7 +263,8 @@ internal class InAppMessageManagerTest { monitoringRepository, sessionStorageManager, userVisitManager, - inAppMessageDelayedManager + inAppMessageDelayedManager, + timeProvider ) coEvery { inAppMessageInteractor.processEventAndConfig() @@ -294,7 +298,8 @@ internal class InAppMessageManagerTest { monitoringRepository, sessionStorageManager, userVisitManager, - inAppMessageDelayedManager + inAppMessageDelayedManager, + timeProvider ) mockkConstructor(NetworkResponse::class) val networkResponse = mockk() @@ -328,7 +333,8 @@ internal class InAppMessageManagerTest { monitoringRepository, sessionStorageManager, userVisitManager, - inAppMessageDelayedManager + inAppMessageDelayedManager, + timeProvider ) mockkConstructor(NetworkResponse::class) val networkResponse = mockk() 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 57bd95ead..548652e78 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,19 +1,13 @@ package cloud.mindbox.mobile_sdk.inapp.presentation import cloud.mindbox.mobile_sdk.di.MindboxDI -import cloud.mindbox.mobile_sdk.inapp.data.dto.PayloadDto -import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppType -import cloud.mindbox.mobile_sdk.inapp.domain.models.Layer 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.assertEquals -import org.junit.Assert.assertNull import org.junit.Before -import org.junit.Test internal class InAppMessageViewDisplayerImplTest { @@ -32,190 +26,4 @@ internal class InAppMessageViewDisplayerImplTest { fun tearDown() { unmockkAll() } - - @Test - fun `getWebViewFromPayload returns WebView for valid redirectUrl payload`() { - val payload = """ - {"${'$'}type":"webview","baseUrl":"https://base","contentUrl":"/content","params":{"a":"b"}} - """.trimIndent() - val imageLayer = Layer.ImageLayer( - action = Layer.ImageLayer.Action.RedirectUrlAction(url = "https://example", payload = payload), - source = Layer.ImageLayer.Source.UrlSource(url = "https://img") - ) - val inApp = InAppType.Snackbar( - inAppId = "inapp-1", - type = PayloadDto.SnackbarDto.SNACKBAR_JSON_NAME, - layers = listOf(imageLayer), - elements = emptyList(), - position = InAppType.Snackbar.Position( - gravity = InAppType.Snackbar.Position.Gravity( - horizontal = InAppType.Snackbar.Position.Gravity.HorizontalGravity.CENTER, - vertical = InAppType.Snackbar.Position.Gravity.VerticalGravity.TOP - ), - margin = InAppType.Snackbar.Position.Margin( - kind = InAppType.Snackbar.Position.Margin.MarginKind.DP, - top = 0, - left = 0, - right = 0, - bottom = 0 - ) - ) - ) - val expected = InAppType.WebView( - inAppId = "inapp-1", - type = PayloadDto.WebViewDto.WEBVIEW_JSON_NAME, - layers = listOf( - Layer.WebViewLayer( - baseUrl = "https://base", - contentUrl = "/content", - type = "webview", - params = mapOf("a" to "b") - ) - ) - ) - val actual = requireNotNull(displayer.getWebViewFromPayload(inApp, inApp.inAppId)) - assertEquals(expected, actual) - } - - @Test - fun `getWebViewFromPayload returns WebView for valid pushPermission payload`() { - val payload = """ - {"${'$'}type":"webview","baseUrl":"https://b","contentUrl":"/c"} - """.trimIndent() - val imageLayer = Layer.ImageLayer( - action = Layer.ImageLayer.Action.PushPermissionAction(payload = payload), - source = Layer.ImageLayer.Source.UrlSource(url = "https://img") - ) - val inApp = InAppType.ModalWindow( - inAppId = "inapp-2", - type = PayloadDto.ModalWindowDto.MODAL_JSON_NAME, - layers = listOf(imageLayer), - elements = emptyList() - ) - val expected = InAppType.WebView( - inAppId = "inapp-2", - type = PayloadDto.WebViewDto.WEBVIEW_JSON_NAME, - layers = listOf( - Layer.WebViewLayer( - baseUrl = "https://b", - contentUrl = "/c", - type = "webview", - params = emptyMap() - ) - ) - ) - val actual = requireNotNull(displayer.getWebViewFromPayload(inApp, inApp.inAppId)) - assertEquals(expected, actual) - } - - @Test - fun `getWebViewFromPayload returns null for empty json object`() { - val payload = "{}" - val imageLayer = Layer.ImageLayer( - action = Layer.ImageLayer.Action.RedirectUrlAction(url = "https://example", payload = payload), - source = Layer.ImageLayer.Source.UrlSource(url = "https://img") - ) - val inApp = InAppType.Snackbar( - inAppId = "inapp-3", - type = PayloadDto.SnackbarDto.SNACKBAR_JSON_NAME, - layers = listOf(imageLayer), - elements = emptyList(), - position = InAppType.Snackbar.Position( - gravity = InAppType.Snackbar.Position.Gravity( - horizontal = InAppType.Snackbar.Position.Gravity.HorizontalGravity.CENTER, - vertical = InAppType.Snackbar.Position.Gravity.VerticalGravity.TOP - ), - margin = InAppType.Snackbar.Position.Margin( - kind = InAppType.Snackbar.Position.Margin.MarginKind.DP, - top = 0, - left = 0, - right = 0, - bottom = 0 - ) - ) - ) - val actual = displayer.getWebViewFromPayload(inApp, inApp.inAppId) - assertNull(actual) - } - - @Test - fun `getWebViewFromPayload returns null for wrong json object`() { - val payload = """ - {"type":"1","baseUrl":"b","contentUrl":"c"} - """.trimIndent() - val imageLayer = Layer.ImageLayer( - action = Layer.ImageLayer.Action.RedirectUrlAction(url = "https://example", payload = payload), - source = Layer.ImageLayer.Source.UrlSource(url = "https://img") - ) - val inApp = InAppType.Snackbar( - inAppId = "inapp-4", - type = PayloadDto.SnackbarDto.SNACKBAR_JSON_NAME, - layers = listOf(imageLayer), - elements = emptyList(), - position = InAppType.Snackbar.Position( - gravity = InAppType.Snackbar.Position.Gravity( - horizontal = InAppType.Snackbar.Position.Gravity.HorizontalGravity.CENTER, - vertical = InAppType.Snackbar.Position.Gravity.VerticalGravity.TOP - ), - margin = InAppType.Snackbar.Position.Margin( - kind = InAppType.Snackbar.Position.Margin.MarginKind.DP, - top = 0, - left = 0, - right = 0, - bottom = 0 - ) - ) - ) - val actual = displayer.getWebViewFromPayload(inApp, inApp.inAppId) - assertNull(actual) - } - - @Test - fun `getWebViewFromPayload returns null for missing fields`() { - val payload = """ - {"${'$'}type":"webview","baseUrl":"https://base"} - """.trimIndent() - val imageLayer = Layer.ImageLayer( - action = Layer.ImageLayer.Action.PushPermissionAction(payload = payload), - source = Layer.ImageLayer.Source.UrlSource(url = "https://img") - ) - val inApp = InAppType.ModalWindow( - inAppId = "inapp-4", - type = PayloadDto.ModalWindowDto.MODAL_JSON_NAME, - layers = listOf(imageLayer), - elements = emptyList() - ) - val actual = displayer.getWebViewFromPayload(inApp, inApp.inAppId) - assertNull(actual) - } - - @Test - fun `getWebViewFromPayload returns null for invalid json`() { - val payload = "not a json" - val imageLayer = Layer.ImageLayer( - action = Layer.ImageLayer.Action.RedirectUrlAction(url = "https://example", payload = payload), - source = Layer.ImageLayer.Source.UrlSource(url = "https://img") - ) - val inApp = InAppType.Snackbar( - inAppId = "inapp-5", - type = PayloadDto.SnackbarDto.SNACKBAR_JSON_NAME, - layers = listOf(imageLayer), - elements = emptyList(), - position = InAppType.Snackbar.Position( - gravity = InAppType.Snackbar.Position.Gravity( - horizontal = InAppType.Snackbar.Position.Gravity.HorizontalGravity.CENTER, - vertical = InAppType.Snackbar.Position.Gravity.VerticalGravity.TOP - ), - margin = InAppType.Snackbar.Position.Margin( - kind = InAppType.Snackbar.Position.Margin.MarginKind.DP, - top = 0, - left = 0, - right = 0, - bottom = 0 - ) - ) - ) - val actual = displayer.getWebViewFromPayload(inApp, inApp.inAppId) - assertNull(actual) - } } diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/BackButtonHandlerTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/BackButtonHandlerTest.kt new file mode 100644 index 000000000..8217688ef --- /dev/null +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/BackButtonHandlerTest.kt @@ -0,0 +1,95 @@ +package cloud.mindbox.mobile_sdk.inapp.presentation.view + +import android.view.KeyEvent +import io.mockk.every +import io.mockk.mockk +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class BackButtonHandlerTest { + + @Test + fun `dispatchKeyEvent returns true and invokes listener for non canceled back action up event`() { + var hasInvokedListener = false + val backButtonHandler = BackButtonHandler { + hasInvokedListener = true + } + val event: KeyEvent = createKeyEvent( + eventAction = KeyEvent.ACTION_UP, + eventKeyCode = KeyEvent.KEYCODE_BACK, + isEventCanceled = false, + ) + val actualResult: Boolean? = backButtonHandler.dispatchKeyEvent(event) + assertTrue(actualResult == true) + assertTrue(hasInvokedListener) + } + + @Test + fun `dispatchKeyEvent returns null and does not invoke listener for back action down event`() { + var hasInvokedListener = false + val backButtonHandler = BackButtonHandler { + hasInvokedListener = true + } + val event: KeyEvent = createKeyEvent( + eventAction = KeyEvent.ACTION_DOWN, + eventKeyCode = KeyEvent.KEYCODE_BACK, + isEventCanceled = false, + ) + val actualResult: Boolean? = backButtonHandler.dispatchKeyEvent(event) + assertNull(actualResult) + assertFalse(hasInvokedListener) + } + + @Test + fun `dispatchKeyEvent returns null and does not invoke listener for canceled back action up event`() { + var hasInvokedListener = false + val backButtonHandler = BackButtonHandler { + hasInvokedListener = true + } + val event: KeyEvent = createKeyEvent( + eventAction = KeyEvent.ACTION_UP, + eventKeyCode = KeyEvent.KEYCODE_BACK, + isEventCanceled = true, + ) + val actualResult: Boolean? = backButtonHandler.dispatchKeyEvent(event) + assertNull(actualResult) + assertFalse(hasInvokedListener) + } + + @Test + fun `dispatchKeyEvent returns null and does not invoke listener for non back action up event`() { + var hasInvokedListener = false + val backButtonHandler = BackButtonHandler { + hasInvokedListener = true + } + val event: KeyEvent = createKeyEvent( + eventAction = KeyEvent.ACTION_UP, + eventKeyCode = KeyEvent.KEYCODE_ENTER, + isEventCanceled = false, + ) + val actualResult: Boolean? = backButtonHandler.dispatchKeyEvent(event) + assertNull(actualResult) + assertFalse(hasInvokedListener) + } + + @Test + fun `dispatchKeyEvent returns null and does not invoke listener for null event`() { + var hasInvokedListener = false + val backButtonHandler = BackButtonHandler { + hasInvokedListener = true + } + val actualResult: Boolean? = backButtonHandler.dispatchKeyEvent(event = null) + assertNull(actualResult) + assertFalse(hasInvokedListener) + } + + private fun createKeyEvent(eventAction: Int, eventKeyCode: Int, isEventCanceled: Boolean): KeyEvent { + return mockk { + every { action } returns eventAction + every { keyCode } returns eventKeyCode + every { isCanceled } returns isEventCanceled + } + } +} diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/DataCollectorTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/DataCollectorTest.kt new file mode 100644 index 000000000..53c0eea7d --- /dev/null +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/DataCollectorTest.kt @@ -0,0 +1,235 @@ +package cloud.mindbox.mobile_sdk.inapp.presentation.view + +import android.content.Context +import android.content.res.Resources +import android.util.DisplayMetrics +import cloud.mindbox.mobile_sdk.inapp.data.managers.SessionStorageManager +import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.PermissionManager +import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.PermissionStatus +import cloud.mindbox.mobile_sdk.models.Configuration +import cloud.mindbox.mobile_sdk.models.EventType +import cloud.mindbox.mobile_sdk.models.InAppEventType +import cloud.mindbox.mobile_sdk.models.TrackVisitData +import cloud.mindbox.mobile_sdk.repository.MindboxPreferences +import cloud.mindbox.mobile_sdk.utils.Constants +import com.google.gson.Gson +import com.google.gson.JsonObject +import com.google.gson.JsonParser +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.unmockkAll +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import java.util.Locale +import android.content.res.Configuration as UiConfiguration + +class DataCollectorTest { + private lateinit var appContext: Context + private lateinit var permissionManager: PermissionManager + private lateinit var sessionStorageManager: SessionStorageManager + private lateinit var resources: Resources + private lateinit var uiConfiguration: UiConfiguration + private val gson: Gson = Gson() + private var previousLocale: Locale = Locale.getDefault() + + @Before + fun onTestStart() { + previousLocale = Locale.getDefault() + appContext = mockk() + resources = mockk() + uiConfiguration = UiConfiguration() + permissionManager = mockk() + sessionStorageManager = SessionStorageManager(timeProvider = mockk()) + val displayMetrics = DisplayMetrics().apply { density = 1f } + every { appContext.resources } returns resources + every { resources.configuration } returns uiConfiguration + every { resources.displayMetrics } returns displayMetrics + mockkObject(MindboxPreferences) + every { MindboxPreferences.localStateVersion } returns 1 + } + + @After + fun onTestFinish() { + Locale.setDefault(previousLocale) + unmockkAll() + } + + @Test + fun `get builds payload with main data and permissions`() { + Locale.setDefault(Locale.forLanguageTag("en-US")) + uiConfiguration.uiMode = UiConfiguration.UI_MODE_NIGHT_NO + every { MindboxPreferences.deviceUuid } returns "device-uuid" + every { MindboxPreferences.localStateVersion } returns 12 + every { MindboxPreferences.firstInitializationTime } returns "2025-01-10T07:40:00Z" + every { MindboxPreferences.userVisitCount } returns 7 + every { permissionManager.getCameraPermissionStatus() } returns PermissionStatus.GRANTED + every { permissionManager.getLocationPermissionStatus() } returns PermissionStatus.DENIED + every { permissionManager.getMicrophonePermissionStatus() } returns PermissionStatus.NOT_DETERMINED + every { permissionManager.getNotificationPermissionStatus() } returns PermissionStatus.RESTRICTED + every { permissionManager.getPhotoLibraryPermissionStatus() } returns PermissionStatus.LIMITED + sessionStorageManager.lastTrackVisitData = TrackVisitData( + ianaTimeZone = "Europe/Moscow", + endpointId = "endpoint-id", + source = "link", + requestUrl = "https://mindbox.cloud/path", + sdkVersionNumeric = Constants.SDK_VERSION_NUMERIC, + ) + sessionStorageManager.inAppTriggerEvent = InAppEventType.OrdinalEvent( + eventType = EventType.AsyncOperation("OpenScreen"), + body = "{\"screen\":\"home\"}", + ) + val dataCollector = DataCollector( + appContext = appContext, + sessionStorageManager = sessionStorageManager, + permissionManager = permissionManager, + configuration = createConfiguration(endpointId = "endpoint-id", versionName = "1.2.3"), + params = mapOf("customKey" to "customValue"), + inAppInsets = InAppInsets(left = 1, top = 2, right = 3, bottom = 4), + gson = gson, + inAppId = "inapp-id", + ) + val actualPayload: String = dataCollector.get() + val actualJson: JsonObject = JsonParser.parseString(actualPayload).asJsonObject + assertEquals("device-uuid", actualJson.get("deviceUUID").asString) + assertEquals("endpoint-id", actualJson.get("endpointId").asString) + assertEquals("2025-01-10T07:40:00Z", actualJson.get("firstInitializationDateTime").asString) + assertEquals("en_US", actualJson.get("locale").asString) + assertEquals("OpenScreen", actualJson.get("operationName").asString) + assertEquals("{\"screen\":\"home\"}", actualJson.get("operationBody").asString) + assertEquals("android", actualJson.get("platform").asString) + assertEquals("light", actualJson.get("theme").asString) + assertEquals(12, actualJson.get("localStateVersion").asInt) + assertEquals("link", actualJson.get("trackVisitSource").asString) + assertEquals("https://mindbox.cloud/path", actualJson.get("trackVisitRequestUrl").asString) + assertEquals("7", actualJson.get("userVisitCount").asString) + assertEquals("1.2.3", actualJson.get("version").asString) + assertEquals("customValue", actualJson.get("customKey").asString) + assertEquals(1, actualJson.getAsJsonObject("insets").get("left").asInt) + assertEquals(2, actualJson.getAsJsonObject("insets").get("top").asInt) + assertEquals(3, actualJson.getAsJsonObject("insets").get("right").asInt) + assertEquals(4, actualJson.getAsJsonObject("insets").get("bottom").asInt) + val permissionsJson: JsonObject = actualJson.getAsJsonObject("permissions") + assertEquals("granted", getPermissionStatus(actualJson, "camera")) + assertFalse(permissionsJson.has("location")) + assertFalse(permissionsJson.has("microphone")) + assertFalse(permissionsJson.has("notifications")) + assertFalse(permissionsJson.has("photoLibrary")) + assertTrue(actualJson.has("sdkVersion")) + assertEquals(Constants.SDK_VERSION_NUMERIC.toString(), actualJson.get("sdkVersionNumeric").asString) + } + + @Test + fun `get ignores blank values and applies params override`() { + Locale.setDefault(Locale.forLanguageTag("ru-RU")) + uiConfiguration.uiMode = UiConfiguration.UI_MODE_NIGHT_YES + every { MindboxPreferences.deviceUuid } returns "" + every { MindboxPreferences.localStateVersion } returns 3 + every { MindboxPreferences.firstInitializationTime } returns null + every { MindboxPreferences.userVisitCount } returns 3 + every { permissionManager.getCameraPermissionStatus() } returns PermissionStatus.GRANTED + every { permissionManager.getLocationPermissionStatus() } returns PermissionStatus.GRANTED + every { permissionManager.getMicrophonePermissionStatus() } returns PermissionStatus.GRANTED + every { permissionManager.getNotificationPermissionStatus() } returns PermissionStatus.GRANTED + every { permissionManager.getPhotoLibraryPermissionStatus() } returns PermissionStatus.GRANTED + sessionStorageManager.inAppTriggerEvent = InAppEventType.AppStartup + sessionStorageManager.lastTrackVisitData = TrackVisitData( + ianaTimeZone = "Europe/Moscow", + endpointId = "endpoint-id", + source = null, + requestUrl = " ", + sdkVersionNumeric = Constants.SDK_VERSION_NUMERIC, + ) + val dataCollector = DataCollector( + appContext = appContext, + sessionStorageManager = sessionStorageManager, + permissionManager = permissionManager, + configuration = createConfiguration(endpointId = "", versionName = "2.0.0"), + params = mapOf("endpointId" to "overridden-endpoint"), + inAppInsets = InAppInsets(), + gson = gson, + inAppId = "inapp-id", + ) + val actualPayload: String = dataCollector.get() + val actualJson: JsonObject = JsonParser.parseString(actualPayload).asJsonObject + assertFalse(actualJson.has("deviceUUID")) + assertFalse(actualJson.has("firstInitializationDateTime")) + assertFalse(actualJson.has("operationName")) + assertFalse(actualJson.has("operationBody")) + assertFalse(actualJson.has("trackVisitSource")) + assertFalse(actualJson.has("trackVisitRequestUrl")) + assertEquals(3, actualJson.get("localStateVersion").asInt) + assertEquals("overridden-endpoint", actualJson.get("endpointId").asString) + assertEquals("dark", actualJson.get("theme").asString) + assertEquals("ru_RU", actualJson.get("locale").asString) + val permissionsJson: JsonObject = actualJson.getAsJsonObject("permissions") + assertEquals(5, permissionsJson.keySet().size) + assertEquals("granted", getPermissionStatus(actualJson, "camera")) + assertEquals("granted", getPermissionStatus(actualJson, "location")) + assertEquals("granted", getPermissionStatus(actualJson, "microphone")) + assertEquals("granted", getPermissionStatus(actualJson, "notifications")) + assertEquals("granted", getPermissionStatus(actualJson, "photoLibrary")) + } + + @Test + fun `get converts insets to CSS pixels when density is not 1f`() { + val density = 2.5f + val displayMetrics = DisplayMetrics().apply { this.density = density } + every { resources.displayMetrics } returns displayMetrics + every { MindboxPreferences.deviceUuid } returns "device-uuid" + every { MindboxPreferences.localStateVersion } returns 5 + every { MindboxPreferences.firstInitializationTime } returns null + every { MindboxPreferences.userVisitCount } returns 0 + every { permissionManager.getCameraPermissionStatus() } returns PermissionStatus.DENIED + every { permissionManager.getLocationPermissionStatus() } returns PermissionStatus.DENIED + every { permissionManager.getMicrophonePermissionStatus() } returns PermissionStatus.DENIED + every { permissionManager.getNotificationPermissionStatus() } returns PermissionStatus.DENIED + every { permissionManager.getPhotoLibraryPermissionStatus() } returns PermissionStatus.DENIED + sessionStorageManager.lastTrackVisitData = null + sessionStorageManager.inAppTriggerEvent = InAppEventType.AppStartup + val inAppInsets = InAppInsets(left = 5, top = 10, right = 15, bottom = 20) + val dataCollector = DataCollector( + appContext = appContext, + sessionStorageManager = sessionStorageManager, + permissionManager = permissionManager, + configuration = createConfiguration(endpointId = "endpoint-id", versionName = "1.0.0"), + params = emptyMap(), + inAppInsets = inAppInsets, + gson = gson, + inAppId = "inapp-id", + ) + val actualPayload = dataCollector.get() + val actualJson = JsonParser.parseString(actualPayload).asJsonObject + val insetsJson = actualJson.getAsJsonObject("insets") + assertNotNull(insetsJson) + assertEquals(2, insetsJson.get("left").asInt) + assertEquals(4, insetsJson.get("top").asInt) + assertEquals(6, insetsJson.get("right").asInt) + assertEquals(8, insetsJson.get("bottom").asInt) + assertEquals(5, actualJson.get("localStateVersion").asInt) + } + + private fun getPermissionStatus(payload: JsonObject, permissionKey: String): String { + return payload + .getAsJsonObject("permissions") + .getAsJsonObject(permissionKey) + .get("status") + .asString + } + + private fun createConfiguration(endpointId: String, versionName: String): Configuration { + return Configuration( + previousInstallationId = "prev-installation", + previousDeviceUUID = "prev-device", + endpointId = endpointId, + domain = "api.test.mindbox.cloud", + packageName = "cloud.mindbox.test", + versionName = versionName, + versionCode = "100", + subscribeCustomerIfCreated = false, + shouldCreateCustomer = true, + ) + } +} diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/MotionServiceBehaviorTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/MotionServiceBehaviorTest.kt new file mode 100644 index 000000000..b2371cbc5 --- /dev/null +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/MotionServiceBehaviorTest.kt @@ -0,0 +1,219 @@ +package cloud.mindbox.mobile_sdk.inapp.presentation.view + +import android.content.Context +import android.hardware.Sensor +import android.hardware.SensorEventListener +import android.hardware.SensorManager +import androidx.lifecycle.Lifecycle +import cloud.mindbox.mobile_sdk.inapp.presentation.view.motion.MotionGesture +import cloud.mindbox.mobile_sdk.inapp.presentation.view.motion.MotionService +import cloud.mindbox.mobile_sdk.inapp.presentation.view.motion.MotionVector +import cloud.mindbox.mobile_sdk.models.Milliseconds +import cloud.mindbox.mobile_sdk.models.Timestamp +import cloud.mindbox.mobile_sdk.utils.SystemTimeProvider +import cloud.mindbox.mobile_sdk.utils.TimeProvider +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +private class FakeTimeProvider(private var nowMs: Long = 0L) : TimeProvider { + override fun currentTimeMillis(): Long = nowMs + + override fun currentTimestamp(): Timestamp = Timestamp(nowMs) + + override fun elapsedSince(startTimeMillis: Timestamp): Milliseconds = + Milliseconds(nowMs - startTimeMillis.ms) + + fun advanceBy(ms: Long) { + nowMs += ms + } +} + +class MotionServiceShakeTest { + + private val phoneThresholdG = 3.0f * SensorManager.GRAVITY_EARTH + + private lateinit var fakeTimeProvider: FakeTimeProvider + private lateinit var motionService: MotionService + + @Before + fun setUp() { + val mockContext: Context = mockk(relaxed = true) + every { mockContext.getSystemService(Context.SENSOR_SERVICE) } returns mockk(relaxed = true) + every { mockContext.resources } returns mockk(relaxed = true) + fakeTimeProvider = FakeTimeProvider(nowMs = 10_000L) + motionService = MotionService( + context = mockContext, + lifecycle = mockk(relaxed = true), + timeProvider = fakeTimeProvider, + ) + } + + @Test + fun `processShake fires callback when accumulated force exceeds threshold`() { + var isDetected = false + motionService.onGestureDetected = { gesture, _ -> isDetected = gesture == MotionGesture.SHAKE } + + motionService.processShake(MotionVector(x = phoneThresholdG + 1f, y = 0f, z = 0f)) + + assertTrue(isDetected) + } + + @Test + fun `processShake does not fire callback when force is below threshold`() { + var isDetected = false + motionService.onGestureDetected = { _, _ -> isDetected = true } + + motionService.processShake(MotionVector(x = phoneThresholdG - 1f, y = 0f, z = 0f)) + + assertFalse(isDetected) + } + + @Test + fun `processShake does not fire callback during cooldown`() { + var detectedCount = 0 + motionService.onGestureDetected = { _, _ -> detectedCount++ } + + motionService.processShake(MotionVector(x = phoneThresholdG + 1f, y = 0f, z = 0f)) + motionService.processShake(MotionVector(x = 0f, y = 0f, z = 0f)) + + assertEquals(1, detectedCount) + } + + @Test + fun `processShake fires again after cooldown expires`() { + var detectedCount = 0 + motionService.onGestureDetected = { _, _ -> detectedCount++ } + + motionService.processShake(MotionVector(x = phoneThresholdG + 1f, y = 0f, z = 0f)) + fakeTimeProvider.advanceBy(900L) + motionService.processShake(MotionVector(x = 0f, y = 0f, z = 0f)) + + assertEquals(2, detectedCount) + } + + @Test + fun `processShake does not fire after exactly cooldown boundary`() { + var detectedCount = 0 + motionService.onGestureDetected = { _, _ -> detectedCount++ } + + motionService.processShake(MotionVector(x = phoneThresholdG + 1f, y = 0f, z = 0f)) + fakeTimeProvider.advanceBy(800L) + motionService.processShake(MotionVector(x = 0f, y = 0f, z = 0f)) + + assertEquals(1, detectedCount) + } + + @Test + fun `processShake sends empty data map for shake gesture`() { + var capturedData: Map? = null + motionService.onGestureDetected = { _, data -> capturedData = data } + + motionService.processShake(MotionVector(x = phoneThresholdG + 1f, y = 0f, z = 0f)) + + assertTrue(capturedData != null && capturedData?.isEmpty() == true) + } + + @Test + fun `processShake accumulates force across multiple frames`() { + var isDetected = false + motionService.onGestureDetected = { _, _ -> isDetected = true } + + val halfThreshold = phoneThresholdG / 2f + motionService.processShake(MotionVector(x = halfThreshold, y = 0f, z = 0f)) + motionService.processShake(MotionVector(x = 0f, y = 0f, z = 0f)) + motionService.processShake(MotionVector(x = halfThreshold, y = 0f, z = 0f)) + + assertTrue(isDetected) + } +} + +class MotionServiceLifecycleTest { + + private lateinit var mockSensorManager: SensorManager + private lateinit var mockContext: Context + private lateinit var motionService: MotionService + + @Before + fun setUp() { + val mockSensor = mockk(relaxed = true) + mockSensorManager = mockk(relaxed = true) + every { mockSensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) } returns mockSensor + every { mockSensorManager.getDefaultSensor(Sensor.TYPE_GRAVITY) } returns mockSensor + + mockContext = mockk(relaxed = true) + every { mockContext.getSystemService(Context.SENSOR_SERVICE) } returns mockSensorManager + every { mockContext.resources } returns mockk(relaxed = true) + + motionService = MotionService( + context = mockContext, + lifecycle = mockk(relaxed = true), + timeProvider = SystemTimeProvider(), + ) + } + + @Test + fun `startMonitoring registers sensor listener`() { + motionService.startMonitoring(setOf(MotionGesture.SHAKE)) + + verify(exactly = 1) { mockSensorManager.registerListener(any(), any(), any()) } + } + + @Test + fun `suspend stops sensors when monitoring is active`() { + motionService.startMonitoring(setOf(MotionGesture.SHAKE)) + + motionService.suspend() + + verify { mockSensorManager.unregisterListener(any()) } + } + + @Test + fun `suspend does nothing when monitoring is not active`() { + motionService.suspend() + + verify(exactly = 0) { mockSensorManager.unregisterListener(any()) } + } + + @Test + fun `resume restarts sensors after suspend`() { + motionService.startMonitoring(setOf(MotionGesture.SHAKE)) + motionService.suspend() + + motionService.resume() + + verify(exactly = 2) { mockSensorManager.registerListener(any(), any(), any()) } + } + + @Test + fun `resume does nothing without prior suspend`() { + motionService.resume() + + verify(exactly = 0) { mockSensorManager.registerListener(any(), any(), any()) } + } + + @Test + fun `stopMonitoring after suspend prevents resume from restarting sensors`() { + motionService.startMonitoring(setOf(MotionGesture.SHAKE)) + motionService.suspend() + motionService.stopMonitoring() + + motionService.resume() + + verify(exactly = 1) { mockSensorManager.registerListener(any(), any(), any()) } + } + + @Test + fun `stopMonitoring unregisters all sensors`() { + motionService.startMonitoring(setOf(MotionGesture.SHAKE, MotionGesture.FLIP)) + + motionService.stopMonitoring() + + verify(atLeast = 1) { mockSensorManager.unregisterListener(any()) } + } +} diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/MotionServiceResolvePositionTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/MotionServiceResolvePositionTest.kt new file mode 100644 index 000000000..cefd974c9 --- /dev/null +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/MotionServiceResolvePositionTest.kt @@ -0,0 +1,249 @@ +package cloud.mindbox.mobile_sdk.inapp.presentation.view + +import android.content.Context +import android.hardware.SensorManager +import androidx.lifecycle.Lifecycle +import cloud.mindbox.mobile_sdk.inapp.presentation.view.motion.DevicePosition +import cloud.mindbox.mobile_sdk.inapp.presentation.view.motion.MotionService +import cloud.mindbox.mobile_sdk.inapp.presentation.view.motion.MotionVector +import cloud.mindbox.mobile_sdk.utils.SystemTimeProvider +import io.mockk.every +import io.mockk.mockk +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test + +class MotionServiceResolvePositionTest { + + private lateinit var motionService: MotionService + + private val enterThreshold = 0.8f * SensorManager.GRAVITY_EARTH + private val exitThreshold = 0.6f * SensorManager.GRAVITY_EARTH + + @Before + fun setUp() { + val mockContext: Context = mockk(relaxed = true) + every { mockContext.getSystemService(Context.SENSOR_SERVICE) } returns mockk(relaxed = true) + every { mockContext.resources } returns mockk(relaxed = true) + motionService = MotionService( + context = mockContext, + lifecycle = mockk(relaxed = true), + timeProvider = SystemTimeProvider(), + ) + } + + @Test + fun `resolvePosition returns faceUp when z is strongly negative and no current position`() { + val inputZ = -enterThreshold - 0.5f + val actualPosition: DevicePosition? = motionService.resolvePosition( + vector = MotionVector(x = 0f, y = 0f, z = inputZ), + current = null, + ) + assertEquals(DevicePosition.FACE_UP, actualPosition) + } + + @Test + fun `resolvePosition returns faceDown when z is strongly positive and no current position`() { + val inputZ = enterThreshold + 0.5f + val actualPosition: DevicePosition? = motionService.resolvePosition( + vector = MotionVector(x = 0f, y = 0f, z = inputZ), + current = null, + ) + assertEquals(DevicePosition.FACE_DOWN, actualPosition) + } + + @Test + fun `resolvePosition returns portrait when y is strongly negative and no current position`() { + val inputY = -enterThreshold - 0.5f + val actualPosition: DevicePosition? = motionService.resolvePosition( + vector = MotionVector(x = 0f, y = inputY, z = 0f), + current = null, + ) + assertEquals(DevicePosition.PORTRAIT, actualPosition) + } + + @Test + fun `resolvePosition returns portraitUpsideDown when y is strongly positive and no current position`() { + val inputY = enterThreshold + 0.5f + val actualPosition: DevicePosition? = motionService.resolvePosition( + vector = MotionVector(x = 0f, y = inputY, z = 0f), + current = null, + ) + assertEquals(DevicePosition.PORTRAIT_UPSIDE_DOWN, actualPosition) + } + + @Test + fun `resolvePosition returns landscapeLeft when x is strongly negative and no current position`() { + val inputX = -enterThreshold - 0.5f + val actualPosition: DevicePosition? = motionService.resolvePosition( + vector = MotionVector(x = inputX, y = 0f, z = 0f), + current = null, + ) + assertEquals(DevicePosition.LANDSCAPE_LEFT, actualPosition) + } + + @Test + fun `resolvePosition returns landscapeRight when x is strongly positive and no current position`() { + val inputX = enterThreshold + 0.5f + val actualPosition: DevicePosition? = motionService.resolvePosition( + vector = MotionVector(x = inputX, y = 0f, z = 0f), + current = null, + ) + assertEquals(DevicePosition.LANDSCAPE_RIGHT, actualPosition) + } + + @Test + fun `resolvePosition returns null when all axes are below enter threshold and no current position`() { + val inputValue = enterThreshold - 0.1f + val actualPosition: DevicePosition? = motionService.resolvePosition( + vector = MotionVector(x = 0f, y = 0f, z = -inputValue), + current = null, + ) + assertNull(actualPosition) + } + + @Test + fun `resolvePosition returns null when all axes are zero and no current position`() { + val actualPosition: DevicePosition? = motionService.resolvePosition( + vector = MotionVector(x = 0f, y = 0f, z = 0f), + current = null, + ) + assertNull(actualPosition) + } + + @Test + fun `resolvePosition retains current faceUp when z is above exit threshold`() { + val inputZ = -(exitThreshold + 0.1f) + val inputCurrentPosition = DevicePosition.FACE_UP + val actualPosition: DevicePosition? = motionService.resolvePosition( + vector = MotionVector(x = 0f, y = 0f, z = inputZ), + current = inputCurrentPosition, + ) + assertEquals(DevicePosition.FACE_UP, actualPosition) + } + + @Test + fun `resolvePosition retains current portrait when y is above exit threshold`() { + val inputY = -(exitThreshold + 0.1f) + val inputCurrentPosition = DevicePosition.PORTRAIT + val actualPosition: DevicePosition? = motionService.resolvePosition( + vector = MotionVector(x = 0f, y = inputY, z = 0f), + current = inputCurrentPosition, + ) + assertEquals(DevicePosition.PORTRAIT, actualPosition) + } + + @Test + fun `resolvePosition retains current landscapeLeft when x is above exit threshold`() { + val inputX = -(exitThreshold + 0.1f) + val inputCurrentPosition = DevicePosition.LANDSCAPE_LEFT + val actualPosition: DevicePosition? = motionService.resolvePosition( + vector = MotionVector(x = inputX, y = 0f, z = 0f), + current = inputCurrentPosition, + ) + assertEquals(DevicePosition.LANDSCAPE_LEFT, actualPosition) + } + + @Test + fun `resolvePosition drops current faceUp when z falls below exit threshold and switches to portrait`() { + val inputZ = -(exitThreshold - 0.1f) + val inputY = -(enterThreshold + 0.5f) + val inputCurrentPosition = DevicePosition.FACE_UP + val actualPosition: DevicePosition? = motionService.resolvePosition( + vector = MotionVector(x = 0f, y = inputY, z = inputZ), + current = inputCurrentPosition, + ) + assertEquals(DevicePosition.PORTRAIT, actualPosition) + } + + @Test + fun `resolvePosition returns null when current position is lost and no axis exceeds enter threshold`() { + val inputZ = -(exitThreshold - 0.1f) + val inputCurrentPosition = DevicePosition.FACE_UP + val actualPosition: DevicePosition? = motionService.resolvePosition( + vector = MotionVector(x = 0f, y = 0f, z = inputZ), + current = inputCurrentPosition, + ) + assertNull(actualPosition) + } + + @Test + fun `resolvePosition picks dominant axis when multiple axes exceed enter threshold`() { + val inputZ = -(enterThreshold + 0.1f) + val inputY = -(enterThreshold + 2.0f) + val actualPosition: DevicePosition? = motionService.resolvePosition( + vector = MotionVector(x = 0f, y = inputY, z = inputZ), + current = null, + ) + assertEquals(DevicePosition.PORTRAIT, actualPosition) + } + + @Test + fun `resolvePosition picks z axis when z magnitude exceeds y magnitude`() { + val inputZ = -(enterThreshold + 1.0f) + val inputY = -(enterThreshold + 0.1f) + val actualPosition: DevicePosition? = motionService.resolvePosition( + vector = MotionVector(x = 0f, y = inputY, z = inputZ), + current = null, + ) + assertEquals(DevicePosition.FACE_UP, actualPosition) + } + + @Test + fun `resolvePosition returns null when z is exactly at enter threshold`() { + val inputZ = -enterThreshold + val actualPosition: DevicePosition? = motionService.resolvePosition( + vector = MotionVector(x = 0f, y = 0f, z = inputZ), + current = null, + ) + assertNull(actualPosition) + } + + @Test + fun `resolvePosition transitions from faceUp to faceDown when z flips to positive`() { + val inputZ = enterThreshold + 0.5f + val actualPosition: DevicePosition? = motionService.resolvePosition( + vector = MotionVector(x = 0f, y = 0f, z = inputZ), + current = DevicePosition.FACE_UP, + ) + assertEquals(DevicePosition.FACE_DOWN, actualPosition) + } + + @Test + fun `resolvePosition transitions from portrait to portraitUpsideDown when y flips to positive`() { + val inputY = enterThreshold + 0.5f + val actualPosition: DevicePosition? = motionService.resolvePosition( + vector = MotionVector(x = 0f, y = inputY, z = 0f), + current = DevicePosition.PORTRAIT, + ) + assertEquals(DevicePosition.PORTRAIT_UPSIDE_DOWN, actualPosition) + } + + @Test + fun `resolvePosition handles multi-step transition from portrait through faceUp to faceDown`() { + val inputStrongZ = -(enterThreshold + 0.5f) + val step1ActualPosition: DevicePosition? = motionService.resolvePosition( + vector = MotionVector(x = 0f, y = 0f, z = inputStrongZ), + current = DevicePosition.PORTRAIT, + ) + assertEquals(DevicePosition.FACE_UP, step1ActualPosition) + + val step2ActualPosition: DevicePosition? = motionService.resolvePosition( + vector = MotionVector(x = 0f, y = 0f, z = enterThreshold + 0.5f), + current = DevicePosition.FACE_UP, + ) + assertEquals(DevicePosition.FACE_DOWN, step2ActualPosition) + } + + @Test + fun `resolvePosition retains portrait when y is above exit threshold even though z is below enter threshold`() { + val inputY = -(exitThreshold + 0.5f) + val inputZ = -(enterThreshold - 1.0f) + val actualPosition: DevicePosition? = motionService.resolvePosition( + vector = MotionVector(x = 0f, y = inputY, z = inputZ), + current = DevicePosition.PORTRAIT, + ) + assertEquals(DevicePosition.PORTRAIT, actualPosition) + } +} diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewActionHandlersTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewActionHandlersTest.kt new file mode 100644 index 000000000..c4f2084fd --- /dev/null +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewActionHandlersTest.kt @@ -0,0 +1,134 @@ +package cloud.mindbox.mobile_sdk.inapp.presentation.view + +import cloud.mindbox.mobile_sdk.annotations.InternalMindboxApi +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.delay +import kotlinx.coroutines.test.* +import org.junit.Assert.* +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class, InternalMindboxApi::class) +class WebViewActionHandlersTest { + + @Test + fun `handleRequest returns payload from registered handler`() { + val handlers: WebViewActionHandlers = WebViewActionHandlers() + val expectedPayload: String = "payload" + val message: BridgeMessage.Request = createRequest(WebViewAction.INIT) + handlers.register(WebViewAction.INIT) { expectedPayload } + val actualResult: Result = handlers.handleRequest(message) + assertTrue(actualResult.isSuccess) + assertEquals(expectedPayload, actualResult.getOrNull()) + } + + @Test + fun `handleRequest returns failure when handler not registered`() { + val handlers: WebViewActionHandlers = WebViewActionHandlers() + val message: BridgeMessage.Request = createRequest(WebViewAction.INIT) + val actualResult: Result = handlers.handleRequest(message) + assertTrue(actualResult.isFailure) + } + + @Test + fun `handleRequestSuspend returns payload from registered suspend handler`() = runTest { + val handlers: WebViewActionHandlers = WebViewActionHandlers() + val expectedPayload: String = "payload" + val message: BridgeMessage.Request = createRequest(WebViewAction.READY) + handlers.registerSuspend(WebViewAction.READY) { expectedPayload } + val actualResult: Result = handlers.handleRequestSuspend(message) + assertTrue(actualResult.isSuccess) + assertEquals(expectedPayload, actualResult.getOrNull()) + } + + @Test + fun `handleRequestSuspend returns failure when suspend handler not registered`() = runTest { + val handlers: WebViewActionHandlers = WebViewActionHandlers() + val message: BridgeMessage.Request = createRequest(WebViewAction.READY) + val actualResult: Result = handlers.handleRequestSuspend(message) + assertTrue(actualResult.isFailure) + } + + @Test + fun `hasSuspendHandler returns true when handler registered`() { + val handlers: WebViewActionHandlers = WebViewActionHandlers() + handlers.registerSuspend(WebViewAction.READY) { BridgeMessage.EMPTY_PAYLOAD } + val actualResult: Boolean = handlers.hasSuspendHandler(WebViewAction.READY) + assertTrue(actualResult) + } + + @Test + fun `hasSuspendHandler returns false when handler not registered`() { + val handlers: WebViewActionHandlers = WebViewActionHandlers() + val actualResult: Boolean = handlers.hasSuspendHandler(WebViewAction.READY) + assertFalse(actualResult) + } + + @Test + fun `handleRequestSuspend completes after delay`() = runTest { + val handlers: WebViewActionHandlers = WebViewActionHandlers() + val expectedPayload: String = "delayed" + val message: BridgeMessage.Request = createRequest(WebViewAction.READY) + handlers.registerSuspend(WebViewAction.READY) { + delay(100) + expectedPayload + } + val dispatcher: TestDispatcher = StandardTestDispatcher(testScheduler) + val deferredResult: Deferred> = + async(dispatcher) { handlers.handleRequestSuspend(message) } + runCurrent() + assertFalse(deferredResult.isCompleted) + advanceTimeBy(99) + runCurrent() + assertFalse(deferredResult.isCompleted) + advanceTimeBy(1) + runCurrent() + assertTrue(deferredResult.isCompleted) + assertEquals(expectedPayload, deferredResult.await().getOrNull()) + } + + @Test + fun `handleRequestSuspend processes multiple requests with different delays`() = runTest { + val handlers: WebViewActionHandlers = WebViewActionHandlers() + val firstPayload: String = "first" + val secondPayload: String = "second" + val firstMessage: BridgeMessage.Request = createRequest(WebViewAction.READY) + val secondMessage: BridgeMessage.Request = createRequest(WebViewAction.INIT) + handlers.registerSuspend(WebViewAction.READY) { + delay(50) + firstPayload + } + handlers.registerSuspend(WebViewAction.INIT) { + delay(150) + secondPayload + } + val dispatcher: TestDispatcher = StandardTestDispatcher(testScheduler) + val firstDeferred: Deferred> = + async(dispatcher) { handlers.handleRequestSuspend(firstMessage) } + val secondDeferred: Deferred> = + async(dispatcher) { handlers.handleRequestSuspend(secondMessage) } + runCurrent() + assertFalse(firstDeferred.isCompleted) + assertFalse(secondDeferred.isCompleted) + advanceTimeBy(50) + runCurrent() + assertTrue(firstDeferred.isCompleted) + assertFalse(secondDeferred.isCompleted) + advanceTimeBy(100) + runCurrent() + assertTrue(secondDeferred.isCompleted) + assertEquals(firstPayload, firstDeferred.await().getOrNull()) + assertEquals(secondPayload, secondDeferred.await().getOrNull()) + } + + private fun createRequest(action: WebViewAction): BridgeMessage.Request { + return BridgeMessage.Request( + version = BridgeMessage.VERSION, + action = action, + payload = BridgeMessage.EMPTY_PAYLOAD, + id = "request-id", + timestamp = 1L, + ) + } +} diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewLinkRouterTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewLinkRouterTest.kt new file mode 100644 index 000000000..beff15ffd --- /dev/null +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewLinkRouterTest.kt @@ -0,0 +1,257 @@ +package cloud.mindbox.mobile_sdk.inapp.presentation.view + +import android.content.ActivityNotFoundException +import android.content.ComponentName +import android.content.Context +import android.content.ContextWrapper +import android.content.Intent +import android.content.IntentFilter +import androidx.test.core.app.ApplicationProvider +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf + +@RunWith(RobolectricTestRunner::class) +internal class WebViewLinkRouterTest { + + private lateinit var context: Context + private lateinit var router: MindboxWebViewLinkRouter + + @Before + fun setUp() { + context = ApplicationProvider.getApplicationContext() + router = MindboxWebViewLinkRouter(context) + } + + @Test + fun `executeOpenLink opens web links from pdf cases`() { + registerBrowsableHandler("https") + val inputUrls: List = listOf( + "https://www.google.com", + "https://habr.com/ru/articles/", + "https://test-site.g.mindbox.ru", + "https://test-site.g.mindbox.ru/some/path?param=1", + "https://mindbox.ru", + "https://mindbox.ru/products", + "https://www.youtube.com/watch?v=abc", + "https://t.me/durov", + ) + inputUrls.forEach { inputUrl: String -> + val actualResult: Result = executeOpenLink(url = inputUrl) + assertTrue(actualResult.isSuccess) + assertEquals(inputUrl, actualResult.getOrNull()) + } + } + + @Test + fun `executeOpenLink opens deeplink schemes from pdf cases`() { + registerBrowsableHandler("pushok") + val inputUrls: List = listOf( + "pushok://", + "pushok://product/123", + "pushok://catalog?category=shoes&sort=price", + ) + inputUrls.forEach { inputUrl: String -> + val actualResult: Result = executeOpenLink(url = inputUrl) + assertTrue(actualResult.isSuccess) + assertEquals(inputUrl, actualResult.getOrNull()) + } + } + + @Test + fun `executeOpenLink opens intent uri`() { + registerBrowsableHandler("myapp") + val intentUrl: String = + "intent://catalog/item/1#Intent;scheme=myapp;S.browser_fallback_url=https%3A%2F%2Fmindbox.ru;end" + val result: Result = router.executeOpenLink("""{"url":"$intentUrl"}""") + assertTrue(result.isSuccess) + assertEquals(intentUrl, result.getOrNull()) + } + + @Test + fun `executeOpenLink opens tg deeplink when handler exists`() { + registerBrowsableHandler("tg") + val inputUrl: String = "tg://resolve?domain=durov" + val result: Result = executeOpenLink(url = inputUrl) + assertTrue(result.isSuccess) + assertEquals(inputUrl, result.getOrNull()) + } + + @Test + fun `executeOpenLink returns error for tg deeplink when handler missing`() { + val activityNotFoundRouter: MindboxWebViewLinkRouter = createRouterWithActivityNotFoundError() + val result: Result = activityNotFoundRouter.executeOpenLink("""{"url":"tg://resolve?domain=durov"}""") + assertFalse(result.isSuccess) + assertErrorContains(result = result, expectedMessagePart = "ActivityNotFoundException") + } + + @Test + fun `executeOpenLink opens system schemes from pdf cases`() { + registerActionHandler(action = Intent.ACTION_DIAL, scheme = "tel") + registerActionHandler(action = Intent.ACTION_SENDTO, scheme = "mailto") + registerActionHandler(action = Intent.ACTION_SENDTO, scheme = "sms") + val inputUrls: List = listOf( + "tel:+1234567890", + "mailto:test@example.com", + "sms:+1234567890", + ) + inputUrls.forEach { inputUrl: String -> + val actualResult: Result = executeOpenLink(url = inputUrl) + assertTrue(actualResult.isSuccess) + assertEquals(inputUrl, actualResult.getOrNull()) + } + } + + @Test + fun `executeOpenLink opens android only schemes when handler exists`() { + registerBrowsableHandler("geo") + registerBrowsableHandler("market") + val geoResult: Result = executeOpenLink(url = "geo:55.7558,37.6173?q=Moscow") + assertTrue(geoResult.isSuccess) + assertEquals("geo:55.7558,37.6173?q=Moscow", geoResult.getOrNull()) + val marketResult: Result = executeOpenLink(url = "market://details?id=com.google.android.gm") + assertTrue(marketResult.isSuccess) + assertEquals("market://details?id=com.google.android.gm", marketResult.getOrNull()) + } + + @Test + fun `executeOpenLink returns error for iOS only schemes without handler`() { + val activityNotFoundRouter: MindboxWebViewLinkRouter = createRouterWithActivityNotFoundError() + val mapsResult: Result = activityNotFoundRouter.executeOpenLink("""{"url":"maps://?q=Moscow"}""") + val appStoreResult: Result = + activityNotFoundRouter.executeOpenLink("""{"url":"itms-apps://apps.apple.com/app/id389801252"}""") + assertFalse(mapsResult.isSuccess) + assertFalse(appStoreResult.isSuccess) + assertErrorContains(result = mapsResult, expectedMessagePart = "ActivityNotFoundException") + assertErrorContains(result = appStoreResult, expectedMessagePart = "ActivityNotFoundException") + } + + @Test + fun `executeOpenLink returns error for blocked schemes from pdf cases`() { + val blockedUrls: List = listOf( + "javascript:alert(1)", + "file:///etc/passwd", + "data:text/html,

blocked

", + "blob:https://example.com/uuid", + ) + blockedUrls.forEach { blockedUrl: String -> + val actualResult: Result = executeOpenLink(url = blockedUrl) + assertFalse(actualResult.isSuccess) + assertErrorContains(result = actualResult, expectedMessagePart = "Blocked URL scheme") + } + } + + @Test + fun `executeOpenLink returns error for invalid or missing scheme urls`() { + val invalidResult: Result = executeOpenLink(url = "not a url at all") + val missingSchemeResult: Result = executeOpenLink(url = "://missing-scheme") + assertFalse(invalidResult.isSuccess) + assertFalse(missingSchemeResult.isSuccess) + assertErrorContains(result = invalidResult, expectedMessagePart = "Invalid URL") + assertErrorContains(result = missingSchemeResult, expectedMessagePart = "Invalid URL") + } + + @Test + fun `executeOpenLink returns error for unknown scheme without activity`() { + val activityNotFoundRouter: MindboxWebViewLinkRouter = createRouterWithActivityNotFoundError() + val result: Result = activityNotFoundRouter.executeOpenLink("""{"url":"nonexistent-scheme://test"}""") + assertFalse(result.isSuccess) + assertErrorContains(result = result, expectedMessagePart = "ActivityNotFoundException") + } + + @Test + fun `executeOpenLink returns error for invalid payload cases from pdf`() { + val nullPayloadResult: Result = router.executeOpenLink(null) + val emptyPayloadResult: Result = router.executeOpenLink("") + val blankPayloadResult: Result = router.executeOpenLink(" ") + val missingUrlResult: Result = router.executeOpenLink("""{"foo":"bar"}""") + val emptyUrlResult: Result = router.executeOpenLink("""{"url":""}""") + val invalidJsonResult: Result = router.executeOpenLink("""{not-json}""") + val notObjectJsonResult: Result = router.executeOpenLink("""["https://mindbox.ru"]""") + val payloadResults: List> = listOf( + nullPayloadResult, + emptyPayloadResult, + blankPayloadResult, + missingUrlResult, + emptyUrlResult, + invalidJsonResult, + notObjectJsonResult, + ) + payloadResults.forEach { actualResult: Result -> + assertFalse(actualResult.isSuccess) + assertErrorContains( + result = actualResult, + expectedMessagePart = "Invalid payload: missing or empty 'url' field", + ) + } + } + + private fun executeOpenLink(url: String): Result { + return router.executeOpenLink("""{"url":"$url"}""") + } + + private fun assertErrorContains( + result: Result, + expectedMessagePart: String, + ) { + val actualError: Throwable? = result.exceptionOrNull() + assertNotNull(actualError) + val actualMessage: String = actualError?.message.orEmpty() + assertTrue(actualMessage.contains(expectedMessagePart)) + } + + private fun createRouterWithActivityNotFoundError(): MindboxWebViewLinkRouter { + val wrappedContext: Context = object : ContextWrapper(context) { + override fun startActivity(intent: Intent) { + throw ActivityNotFoundException("No activity found") + } + } + return MindboxWebViewLinkRouter(wrappedContext) + } + + private fun registerBrowsableHandler(scheme: String) { + registerHandler( + action = Intent.ACTION_VIEW, + scheme = scheme, + isBrowsable = true, + ) + } + + private fun registerActionHandler( + action: String, + scheme: String, + ) { + registerHandler( + action = action, + scheme = scheme, + isBrowsable = false, + ) + } + + private fun registerHandler( + action: String, + scheme: String, + isBrowsable: Boolean, + ) { + val componentName: ComponentName = ComponentName("com.example", "TestActivityFor_${action}_$scheme") + val packageManager = shadowOf(RuntimeEnvironment.getApplication().packageManager) + packageManager.addActivityIfNotPresent(componentName) + packageManager.addIntentFilterForActivity( + componentName, + IntentFilter(action).apply { + addCategory(Intent.CATEGORY_DEFAULT) + if (isBrowsable) { + addCategory(Intent.CATEGORY_BROWSABLE) + } + addDataScheme(scheme) + } + ) + } +} diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewLocalStateStoreTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewLocalStateStoreTest.kt new file mode 100644 index 000000000..9ece5c5f0 --- /dev/null +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewLocalStateStoreTest.kt @@ -0,0 +1,205 @@ +package cloud.mindbox.mobile_sdk.inapp.presentation.view + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import cloud.mindbox.mobile_sdk.managers.SharedPreferencesManager +import cloud.mindbox.mobile_sdk.repository.MindboxPreferences +import org.json.JSONObject +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class WebViewLocalStateStoreTest { + + companion object { + private const val LOCAL_STATE_FILE_NAME: String = "mindbox_webview_local_state" + } + + private lateinit var context: Context + private lateinit var store: WebViewLocalStateStore + + @Before + fun setUp() { + context = ApplicationProvider.getApplicationContext() + context.getSharedPreferences(LOCAL_STATE_FILE_NAME, Context.MODE_PRIVATE).edit().clear().apply() + context.getSharedPreferences("preferences", Context.MODE_PRIVATE).edit().clear().apply() + SharedPreferencesManager.with(context) + MindboxPreferences.localStateVersion = 1 + store = WebViewLocalStateStore(context) + } + + @Test + fun `getState returns default version and empty data when storage is empty`() { + val actualResponse: JSONObject = store.getState("""{"data":[]}""").toJsonObject() + assertEquals(1, actualResponse.getInt("version")) + assertEquals(0, actualResponse.getJSONObject("data").length()) + } + + @Test + fun `get with specific keys returns only requested keys`() { + store.initState("""{"data":{"key1":"value1","key2":"value2"},"version":2}""") + val actualResponse: JSONObject = store.getState("""{"data":["key1"]}""").toJsonObject() + assertEquals(2, actualResponse.getInt("version")) + val actualData: JSONObject = actualResponse.getJSONObject("data") + assertEquals("value1", actualData.getString("key1")) + assertFalse(actualData.has("key2")) + } + + @Test + fun `get with empty keys returns all stored keys`() { + store.setState("""{"data":{"key1":"value1","key2":"value2"}}""") + val actualResponse: JSONObject = store.getState("""{"data":[]}""").toJsonObject() + val actualData: JSONObject = actualResponse.getJSONObject("data") + assertEquals(2, actualData.length()) + assertEquals("value1", actualData.getString("key1")) + assertEquals("value2", actualData.getString("key2")) + } + + @Test + fun `get returns current version from preferences`() { + MindboxPreferences.localStateVersion = 5 + val actualResponse: JSONObject = store.getState("""{"data":[]}""").toJsonObject() + assertEquals(5, actualResponse.getInt("version")) + } + + @Test + fun `setState updates values and removes fields with null`() { + store.initState("""{"data":{"key1":"value1","key2":"value2"},"version":3}""") + store.setState("""{"data":{"key1":"updated","key2":null,"key3":"value3"}}""") + val actualResponse: JSONObject = store.getState("""{"data":[]}""").toJsonObject() + assertEquals(3, actualResponse.getInt("version")) + val actualData: JSONObject = actualResponse.getJSONObject("data") + assertEquals("updated", actualData.getString("key1")) + assertFalse(actualData.has("key2")) + assertEquals("value3", actualData.getString("key3")) + } + + @Test + fun `initState returns error when requested version is lower than current`() { + store.initState("""{"data":{"key":"value"},"version":5}""") + val actualError: IllegalArgumentException = assertThrows(IllegalArgumentException::class.java) { + store.initState("""{"data":{"key":"next"},"version":0}""") + } + assertTrue(actualError.message?.contains("Version must be greater than 0") == true) + } + + @Test + fun `initState returns error when data field is missing`() { + val actualError: Exception = assertThrows(Exception::class.java) { + store.initState("""{"version":2}""") + } + assertTrue(actualError.message?.isNotBlank() == true) + } + + @Test + fun `initState stores version in sdk preferences`() { + store.initState("""{"data":{"key":"value"},"version":7}""") + assertEquals(7, MindboxPreferences.localStateVersion) + } + + @Test + fun `setState stores each data key as separate preference key`() { + store.setState("""{"data":{"firstKey":"firstValue","secondKey":"secondValue"}}""") + val localStatePreferences = context.getSharedPreferences(LOCAL_STATE_FILE_NAME, Context.MODE_PRIVATE) + assertEquals("firstValue", localStatePreferences.getString("firstKey", null)) + assertEquals("secondValue", localStatePreferences.getString("secondKey", null)) + assertFalse(localStatePreferences.contains("local_state_data_json")) + } + + @Test + fun `get missing keys excludes absent keys from response`() { + store.initState("""{"data":{"existing":"value"},"version":2}""") + val actualResponse: JSONObject = store.getState("""{"data":["existing","missing"]}""").toJsonObject() + val actualData: JSONObject = actualResponse.getJSONObject("data") + assertTrue(actualData.has("existing")) + assertTrue(actualData.has("missing")) + assertEquals(2, actualData.length()) + } + + @Test + fun `setState returns only affected keys`() { + store.initState("""{"data":{"oldKey":"oldValue"},"version":4}""") + val actualResponse: JSONObject = store.setState("""{"data":{"newKey":"newValue"}}""").toJsonObject() + val actualData: JSONObject = actualResponse.getJSONObject("data") + assertTrue(actualData.has("newKey")) + assertFalse(actualData.has("oldKey")) + } + + @Test + fun `setState does not change version`() { + store.initState("""{"data":{"key":"value"},"version":8}""") + val actualResponse: JSONObject = store.setState("""{"data":{"key":"updated"}}""").toJsonObject() + assertEquals(8, actualResponse.getInt("version")) + assertEquals(8, MindboxPreferences.localStateVersion) + } + + @Test + fun `initState merges with existing data`() { + store.setState("""{"data":{"base":"base-value","keep":"keep-value"}}""") + store.initState("""{"data":{"base":"updated-base","added":"added-value"},"version":3}""") + val actualResponse: JSONObject = store.getState("""{"data":[]}""").toJsonObject() + val actualData: JSONObject = actualResponse.getJSONObject("data") + assertEquals("updated-base", actualData.getString("base")) + assertEquals("keep-value", actualData.getString("keep")) + assertEquals("added-value", actualData.getString("added")) + } + + @Test + fun `initState rejects negative version`() { + val actualError: IllegalArgumentException = assertThrows(IllegalArgumentException::class.java) { + store.initState("""{"data":{"key":"value"},"version":-1}""") + } + assertTrue(actualError.message?.contains("Version must be greater than 0") == true) + } + + @Test + fun `initState rejects zero version`() { + val actualError: IllegalArgumentException = assertThrows(IllegalArgumentException::class.java) { + store.initState("""{"data":{"key":"value"},"version":0}""") + } + assertTrue(actualError.message?.contains("Version must be greater than 0") == true) + } + + @Test + fun `initState does not write version when rejected`() { + store.initState("""{"data":{"key":"value"},"version":6}""") + assertThrows(IllegalArgumentException::class.java) { + store.initState("""{"data":{"key":"next"},"version":-10}""") + } + assertEquals(6, MindboxPreferences.localStateVersion) + } + + @Test + fun `full flow init set get works correctly`() { + store.initState("""{"data":{"k1":"v1"},"version":5}""") + store.setState("""{"data":{"k2":"v2","k1":"v1-updated"}}""") + val actualResponse: JSONObject = store.getState("""{"data":["k1","k2"]}""").toJsonObject() + val actualData: JSONObject = actualResponse.getJSONObject("data") + assertEquals("v1-updated", actualData.getString("k1")) + assertEquals("v2", actualData.getString("k2")) + assertEquals(5, actualResponse.getInt("version")) + } + + @Test + fun `set null then get returns removed key as empty`() { + store.setState("""{"data":{"keyToDelete":"value"}}""") + store.setState("""{"data":{"keyToDelete":null}}""") + val actualResponse: JSONObject = store.getState("""{"data":[]}""").toJsonObject() + val actualData: JSONObject = actualResponse.getJSONObject("data") + assertFalse(actualData.has("keyToDelete")) + } + + @Test + fun `initState removes key when value is null`() { + store.setState("""{"data":{"keyToDelete":"value"}}""") + store.initState("""{"data":{"keyToDelete":null},"version":2}""") + val actualResponse: JSONObject = store.getState("""{"data":[]}""").toJsonObject() + val actualData: JSONObject = actualResponse.getJSONObject("data") + assertFalse(actualData.has("keyToDelete")) + } + + private fun String.toJsonObject(): JSONObject = JSONObject(this) +} diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewOperationExecutorTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewOperationExecutorTest.kt new file mode 100644 index 000000000..ee49e2892 --- /dev/null +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewOperationExecutorTest.kt @@ -0,0 +1,174 @@ +package cloud.mindbox.mobile_sdk.inapp.presentation.view + +import android.app.Application +import cloud.mindbox.mobile_sdk.managers.MindboxEventManager +import cloud.mindbox.mobile_sdk.models.MindboxError +import io.mockk.* +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.fail +import org.junit.Before +import org.junit.Test + +class WebViewOperationExecutorTest { + + private lateinit var executor: MindboxWebViewOperationExecutor + + @Before + fun onTestStart() { + executor = MindboxWebViewOperationExecutor() + mockkObject(MindboxEventManager) + } + + @After + fun onTestEnd() { + unmockkObject(MindboxEventManager) + } + + @Test + fun `executeAsyncOperation sends parsed operation and body to event manager`() { + val context: Application = mockk() + val payload: String = """{"operation":"OpenScreen","body":{"screen":"home"}}""" + every { MindboxEventManager.asyncOperation(any(), any(), any()) } returns Unit + executor.executeAsyncOperation(context, payload) + verify(exactly = 1) { + MindboxEventManager.asyncOperation( + context = context, + name = "OpenScreen", + body = """{"screen":"home"}""", + ) + } + } + + @Test + fun `executeAsyncOperation throws when payload misses operation`() { + val context: Application = mockk() + val payload: String = """{"body":{"screen":"home"}}""" + try { + executor.executeAsyncOperation(context, payload) + fail("Expected IllegalArgumentException") + } catch (exception: IllegalArgumentException) { + assertEquals("Operation is not provided", exception.message) + } + verify(exactly = 0) { MindboxEventManager.asyncOperation(any(), any(), any()) } + } + + @Test + fun `executeAsyncOperation throws when payload misses body`() { + val context: Application = mockk() + val payload: String = """{"operation":"OpenScreen"}""" + try { + executor.executeAsyncOperation(context, payload) + fail("Expected IllegalArgumentException") + } catch (exception: IllegalArgumentException) { + assertEquals("Body is not provided", exception.message) + } + verify(exactly = 0) { MindboxEventManager.asyncOperation(any(), any(), any()) } + } + + @Test + fun `executeAsyncOperation throws when payload is invalid json empty or null`() { + val context: Application = mockk() + val payloads: List = listOf("not-json", "", null) + payloads.forEach { payload: String? -> + try { + executor.executeAsyncOperation(context, payload) + fail("Expected exception for payload: $payload") + } catch (exception: Exception) { + // Expected: payload cannot be parsed to required JSON object. + } + } + verify(exactly = 0) { MindboxEventManager.asyncOperation(any(), any(), any()) } + } + + @Test + fun `executeSyncOperation returns response when event manager succeeds`() = runTest { + val payload: String = """{"operation":"OpenScreen","body":{"screen":"home"}}""" + val expectedResponse: String = """{"result":"ok"}""" + every { + MindboxEventManager.syncOperation( + name = any(), + bodyJson = any(), + onSuccess = any(), + onError = any(), + ) + } answers { + val onSuccess: (String) -> Unit = arg(2) + onSuccess(expectedResponse) + } + val actualResponse: String = executor.executeSyncOperation(payload) + assertEquals(expectedResponse, actualResponse) + verify(exactly = 1) { + MindboxEventManager.syncOperation( + name = "OpenScreen", + bodyJson = """{"screen":"home"}""", + onSuccess = any(), + onError = any(), + ) + } + } + + @Test + fun `executeSyncOperation throws IllegalStateException when event manager returns error`() = runTest { + val payload: String = """{"operation":"OpenScreen","body":{"screen":"home"}}""" + val expectedError: MindboxError = MindboxError.Unknown(Throwable("network failure")) + every { + MindboxEventManager.syncOperation( + name = any(), + bodyJson = any(), + onSuccess = any(), + onError = any(), + ) + } answers { + val onError: (MindboxError) -> Unit = arg(3) + onError(expectedError) + } + try { + executor.executeSyncOperation(payload) + fail("Expected IllegalStateException") + } catch (exception: IllegalStateException) { + assertEquals(expectedError.toJson(), exception.message) + } + } + + @Test + fun `executeSyncOperation throws when payload misses body`() = runTest { + val payload: String = """{"operation":"OpenScreen"}""" + try { + executor.executeSyncOperation(payload) + fail("Expected IllegalArgumentException") + } catch (exception: IllegalArgumentException) { + assertEquals("Body is not provided", exception.message) + } + verify(exactly = 0) { + MindboxEventManager.syncOperation( + name = any(), + bodyJson = any(), + onSuccess = any(), + onError = any(), + ) + } + } + + @Test + fun `executeSyncOperation throws when payload is invalid json empty or null`() = runTest { + val payloads: List = listOf("not-json", "", null) + payloads.forEach { payload: String? -> + try { + executor.executeSyncOperation(payload) + fail("Expected exception for payload: $payload") + } catch (exception: Exception) { + // Expected: payload cannot be parsed to required JSON object. + } + } + verify(exactly = 0) { + MindboxEventManager.syncOperation( + name = any(), + bodyJson = any(), + onSuccess = any(), + onError = any(), + ) + } + } +} diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewPermissionBridgeSerializationTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewPermissionBridgeSerializationTest.kt new file mode 100644 index 000000000..2fef5238b --- /dev/null +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewPermissionBridgeSerializationTest.kt @@ -0,0 +1,51 @@ +package cloud.mindbox.mobile_sdk.inapp.presentation.view + +import cloud.mindbox.mobile_sdk.annotations.InternalMindboxApi +import com.google.gson.Gson +import org.junit.Assert.assertEquals +import org.junit.Test + +@OptIn(InternalMindboxApi::class) +class WebViewPermissionBridgeSerializationTest { + + private val gson: Gson = Gson() + + @Test + fun `toJson serializes denied result correctly`() { + val payload = PermissionActionResponse( + result = PermissionRequestStatus.DENIED, + dialogShown = true, + details = PermissionActionDetails( + required = true, + shouldShowRequestPermissionRationale = false + ) + ) + val json: String = gson.toJson(payload) + val parsedPayload: PermissionResponseTestPayload = gson.fromJson(json, PermissionResponseTestPayload::class.java) + assertEquals("denied", parsedPayload.result) + assertEquals(true, parsedPayload.dialogShown) + assertEquals(true, parsedPayload.details.required) + assertEquals(false, parsedPayload.details.shouldShowRequestPermissionRationale) + } + + @Test + fun `fromJson maps permission request action to enum`() { + val message: ActionWrapper = gson.fromJson("""{"action":"permission.request"}""", ActionWrapper::class.java) + assertEquals(WebViewAction.PERMISSION_REQUEST, message.action) + } + + private data class PermissionResponseTestPayload( + val result: String, + val dialogShown: Boolean, + val details: PermissionDetailsTestPayload + ) + + private data class PermissionDetailsTestPayload( + val required: Boolean, + val shouldShowRequestPermissionRationale: Boolean? + ) + + private data class ActionWrapper( + val action: WebViewAction + ) +} diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewPermissionRequesterTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewPermissionRequesterTest.kt new file mode 100644 index 000000000..31b2d66b8 --- /dev/null +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewPermissionRequesterTest.kt @@ -0,0 +1,152 @@ +package cloud.mindbox.mobile_sdk.inapp.presentation.view + +import android.app.Activity +import android.os.Build +import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.PermissionManager +import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.PermissionStatus +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Test + +class WebViewPermissionRequesterTest { + + @Test + fun `requestPermission returns granted when push permission already granted`() = runTest { + val pushPermissionLauncher: PushPermissionLauncher = FakePushPermissionLauncher( + PushPermissionRequestResult( + status = PermissionRequestStatus.DENIED, + shouldShowRequestPermissionRationale = false, + dialogShown = false + ) + ) + val permissionManager: PermissionManager = FakePermissionManager( + pushStatus = PermissionStatus.GRANTED + ) + val requester: WebViewPermissionRequester = WebViewPermissionRequesterImpl( + context = mockk(relaxed = true), + pushPermissionLauncher = pushPermissionLauncher, + permissionManager = permissionManager, + sdkIntProvider = { Build.VERSION_CODES.TIRAMISU } + ) + val actualResult: PermissionActionResponse = requester.requestPermission( + activity = mockk(relaxed = true), + permissionType = PermissionType.PUSH_NOTIFICATIONS + ) + assertEquals(PermissionRequestStatus.GRANTED, actualResult.result) + assertEquals(false, actualResult.dialogShown) + assertEquals(true, actualResult.details.required) + assertEquals(null, actualResult.details.shouldShowRequestPermissionRationale) + } + + @Test + fun `requestPermission returns granted when push permission request is granted without dialog`() = runTest { + val pushPermissionLauncher: PushPermissionLauncher = FakePushPermissionLauncher( + PushPermissionRequestResult( + status = PermissionRequestStatus.GRANTED, + shouldShowRequestPermissionRationale = false, + dialogShown = false + ) + ) + val permissionManager: PermissionManager = FakePermissionManager( + pushStatus = PermissionStatus.DENIED + ) + val requester: WebViewPermissionRequester = WebViewPermissionRequesterImpl( + context = mockk(relaxed = true), + pushPermissionLauncher = pushPermissionLauncher, + permissionManager = permissionManager, + sdkIntProvider = { Build.VERSION_CODES.TIRAMISU } + ) + val actualResult: PermissionActionResponse = requester.requestPermission( + activity = mockk(relaxed = true), + permissionType = PermissionType.PUSH_NOTIFICATIONS + ) + assertEquals(PermissionRequestStatus.GRANTED, actualResult.result) + assertEquals(false, actualResult.dialogShown) + assertEquals(true, actualResult.details.required) + assertEquals(false, actualResult.details.shouldShowRequestPermissionRationale) + } + + @Test + fun `requestPermission returns denied when push permission request is denied`() = runTest { + val pushPermissionLauncher: PushPermissionLauncher = FakePushPermissionLauncher( + PushPermissionRequestResult( + status = PermissionRequestStatus.DENIED, + shouldShowRequestPermissionRationale = true, + dialogShown = true + ) + ) + val permissionManager: PermissionManager = FakePermissionManager( + pushStatus = PermissionStatus.DENIED + ) + val requester: WebViewPermissionRequester = WebViewPermissionRequesterImpl( + context = mockk(relaxed = true), + pushPermissionLauncher = pushPermissionLauncher, + permissionManager = permissionManager, + sdkIntProvider = { Build.VERSION_CODES.TIRAMISU } + ) + val actualResult: PermissionActionResponse = requester.requestPermission( + activity = mockk(relaxed = true), + permissionType = PermissionType.PUSH_NOTIFICATIONS + ) + assertEquals(PermissionRequestStatus.DENIED, actualResult.result) + assertEquals(true, actualResult.dialogShown) + assertEquals(true, actualResult.details.required) + assertEquals(true, actualResult.details.shouldShowRequestPermissionRationale) + } + + @Test + fun `requestPermission returns denied without dialog for push on sdk lower than tiramisu`() = runTest { + val pushPermissionLauncher: PushPermissionLauncher = FakePushPermissionLauncher( + PushPermissionRequestResult( + status = PermissionRequestStatus.GRANTED, + shouldShowRequestPermissionRationale = false, + dialogShown = false + ) + ) + val permissionManager: PermissionManager = FakePermissionManager( + pushStatus = PermissionStatus.DENIED + ) + val requester: WebViewPermissionRequester = WebViewPermissionRequesterImpl( + context = mockk(relaxed = true), + pushPermissionLauncher = pushPermissionLauncher, + permissionManager = permissionManager, + sdkIntProvider = { Build.VERSION_CODES.S } + ) + val actualResult: PermissionActionResponse = requester.requestPermission( + activity = mockk(relaxed = true), + permissionType = PermissionType.PUSH_NOTIFICATIONS + ) + assertEquals(PermissionRequestStatus.DENIED, actualResult.result) + assertEquals(false, actualResult.dialogShown) + assertEquals(false, actualResult.details.required) + assertEquals(null, actualResult.details.shouldShowRequestPermissionRationale) + } + + private class FakePushPermissionLauncher( + private val result: PushPermissionRequestResult + ) : PushPermissionLauncher { + override suspend fun requestPermission(activity: Activity): PushPermissionRequestResult { + return result + } + } + + private class FakePermissionManager( + private val cameraStatus: PermissionStatus = PermissionStatus.DENIED, + private val geoStatus: PermissionStatus = PermissionStatus.DENIED, + private val microphoneStatus: PermissionStatus = PermissionStatus.DENIED, + private val pushStatus: PermissionStatus = PermissionStatus.DENIED, + private val libraryStatus: PermissionStatus = PermissionStatus.DENIED, + ) : PermissionManager { + + override fun getCameraPermissionStatus(): PermissionStatus = cameraStatus + + override fun getLocationPermissionStatus(): PermissionStatus = geoStatus + + override fun getMicrophonePermissionStatus(): PermissionStatus = microphoneStatus + + override fun getNotificationPermissionStatus(): PermissionStatus = pushStatus + + override fun getPhotoLibraryPermissionStatus(): PermissionStatus = libraryStatus + } +} 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 c28f1a36f..5a22a40b7 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 @@ -4,13 +4,11 @@ import cloud.mindbox.mobile_sdk.Mindbox import cloud.mindbox.mobile_sdk.inapp.data.managers.SessionStorageManager import cloud.mindbox.mobile_sdk.models.Milliseconds import cloud.mindbox.mobile_sdk.models.SettingsStub.Companion.getSlidingExpiration -import cloud.mindbox.mobile_sdk.models.Timestamp import cloud.mindbox.mobile_sdk.models.operation.response.InAppConfigResponse import cloud.mindbox.mobile_sdk.models.operation.response.SettingsDto import cloud.mindbox.mobile_sdk.models.toTimestamp import cloud.mindbox.mobile_sdk.pushes.PushNotificationManager import cloud.mindbox.mobile_sdk.repository.MindboxPreferences -import cloud.mindbox.mobile_sdk.utils.SystemTimeProvider import cloud.mindbox.mobile_sdk.utils.TimeProvider import io.mockk.* import kotlinx.coroutines.test.runTest @@ -21,21 +19,18 @@ import org.junit.Test class MobileConfigSettingsManagerImplTest { + private val mockTimeProvider = mockk() private lateinit var sessionStorageManager: SessionStorageManager private lateinit var mobileConfigSettingsManager: MobileConfigSettingsManagerImpl private val now = 100_000L @Before fun onTestStart() { - val realSessionStorageManager = SessionStorageManager(SystemTimeProvider()) - sessionStorageManager = spyk(realSessionStorageManager) - mobileConfigSettingsManager = MobileConfigSettingsManagerImpl(mockk(), sessionStorageManager, object : TimeProvider { - override fun currentTimeMillis(): Long = now - - override fun currentTimestamp(): Timestamp { - return now.toTimestamp() - } - }) + every { mockTimeProvider.currentTimeMillis() } returns now + every { mockTimeProvider.currentTimestamp() } returns now.toTimestamp() + + sessionStorageManager = spyk(SessionStorageManager(mockTimeProvider)) + mobileConfigSettingsManager = MobileConfigSettingsManagerImpl(mockk(), sessionStorageManager, mockTimeProvider) mockkObject(Mindbox) mockkObject(MindboxPreferences) mockkObject(MindboxEventManager) @@ -148,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) + val config = InAppConfigResponse(null, null, SettingsDto(null, null, null, null, null), null) mobileConfigSettingsManager.checkPushTokenKeepalive(config) verify(exactly = 0) { MindboxEventManager.appKeepalive(any(), any()) } diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/models/InAppStub.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/models/InAppStub.kt index 6b5482026..7d374bb6c 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/models/InAppStub.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/models/InAppStub.kt @@ -22,7 +22,8 @@ internal class InAppStub { ), form = Form(variants = listOf(getModalWindow())), isPriority = false, - delayTime = null + delayTime = null, + tags = null, ) fun getInAppDto(): InAppDto = InAppDto( @@ -32,7 +33,8 @@ internal class InAppStub { targeting = (TreeTargetingDto.TrueNodeDto("")), form = FormDto(variants = listOf(getModalWindowDto())), isPriority = false, - delayTime = null + delayTime = null, + tags = null, ) fun getFrequencyOnceDto(): FrequencyDto.FrequencyOnceDto = FrequencyDto.FrequencyOnceDto( @@ -157,7 +159,8 @@ internal class InAppStub { sdkVersion = null, targeting = null, frequency = null, - form = null + form = null, + tags = null, ) } @@ -311,6 +314,19 @@ internal class InAppStub { type = "", inAppId = "", layers = listOf(), elements = listOf() ) + fun getWebView() = InAppType.WebView( + inAppId = "", + type = "webview", + layers = listOf( + Layer.WebViewLayer( + baseUrl = "https://inapp.local/popup", + contentUrl = "https://inapp-dev.html", + type = "webview", + params = mapOf("formId" to "73379") + ) + ) + ) + val viewProductNode: ViewProductNode = ViewProductNode( type = "", kind = KindSubstring.SUBSTRING, value = "" ) diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/models/SettingsStub.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/models/SettingsStub.kt index 58f912aee..4767fd3bf 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/models/SettingsStub.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/models/SettingsStub.kt @@ -21,7 +21,8 @@ internal class SettingsStub { config = config, pushTokenKeepalive = pushTokenKeepalive ), - inapp = null + inapp = null, + featureToggles = null ), abtests = null ) @@ -43,7 +44,8 @@ internal class SettingsStub { maxInappsPerSession = maxInappsPerSession, maxInappsPerDay = maxInappsPerDay, minIntervalBetweenShows = minIntervalBetweenShows - ) + ), + featureToggles = emptyMap() ), abtests = null ) diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/utils/MigrationManagerTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/utils/MigrationManagerTest.kt index 03b9ccf3c..c8a3410ab 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/utils/MigrationManagerTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/utils/MigrationManagerTest.kt @@ -1,5 +1,10 @@ package cloud.mindbox.mobile_sdk.utils +import cloud.mindbox.mobile_sdk.di.MindboxDI +import cloud.mindbox.mobile_sdk.di.modules.AppModule +import cloud.mindbox.mobile_sdk.models.convertToIso8601String +import cloud.mindbox.mobile_sdk.models.toTimestamp +import cloud.mindbox.mobile_sdk.pushes.PrefPushToken import cloud.mindbox.mobile_sdk.repository.MindboxPreferences import com.google.gson.Gson import com.google.gson.reflect.TypeToken @@ -13,6 +18,10 @@ class MigrationManagerTest { @Before fun setUp() { mockkObject(MindboxPreferences) + mockkObject(MindboxDI) + val appModule = mockk(relaxed = true) + every { appModule.gson } returns Gson() + every { MindboxDI.appModule } returns appModule } @Test @@ -43,7 +52,7 @@ class MigrationManagerTest { } returns expectedNewMapString val mm = MigrationManager(mockk()) - every { MindboxPreferences.versionCode } returns Constants.SDK_VERSION_CODE - 1 + every { MindboxPreferences.versionCode } returns 2 mm.migrateAll() coVerify(exactly = 1) { MindboxPreferences.shownInApps = expectedNewMapString @@ -52,4 +61,74 @@ class MigrationManagerTest { MindboxPreferences.shownInApps = expectedShownInappsWithListShowString } } + + @Test + fun `version2150 saves minimum push token timestamp as first initialization time`() = runTest { + val expectedTimestamp = 1000L + every { MindboxPreferences.versionCode } returns 3 + every { MindboxPreferences.firstInitializationTime } returns null + every { MindboxPreferences.pushTokens } returns mapOf( + "FCM" to PrefPushToken("tokenFCM", expectedTimestamp), + "HMS" to PrefPushToken("tokenHMS", 2000L), + ) + every { MindboxPreferences.firstInitializationTime = any() } just runs + every { MindboxPreferences.versionCode = any() } just runs + + MigrationManager(mockk()).migrateAll() + + verify(exactly = 1) { + MindboxPreferences.firstInitializationTime = expectedTimestamp + .toTimestamp() + .convertToIso8601String() + } + } + + @Test + fun `version2150 does not override existing first initialization time`() = runTest { + every { MindboxPreferences.versionCode } returns 3 + every { MindboxPreferences.firstInitializationTime } returns "2025-01-10T07:40:00Z" + every { MindboxPreferences.versionCode = any() } just runs + + MigrationManager(mockk()).migrateAll() + + verify(exactly = 0) { + MindboxPreferences.firstInitializationTime = any() + } + } + + @Test + fun `version2150 uses current time when no push tokens available`() = runTest { + every { MindboxPreferences.versionCode } returns 3 + every { MindboxPreferences.firstInitializationTime } returns null + every { MindboxPreferences.pushTokens } returns emptyMap() + every { MindboxPreferences.firstInitializationTime = any() } just runs + every { MindboxPreferences.versionCode = any() } just runs + + MigrationManager(mockk()).migrateAll() + + verify(exactly = 1) { + MindboxPreferences.firstInitializationTime = any() + } + } + + @Test + fun `version2150 filters out zero push token timestamps`() = runTest { + val expectedTimestamp = 5000L + every { MindboxPreferences.versionCode } returns 3 + every { MindboxPreferences.firstInitializationTime } returns null + every { MindboxPreferences.pushTokens } returns mapOf( + "FCM" to PrefPushToken("tokenFCM", 0L), + "HMS" to PrefPushToken("tokenHMS", expectedTimestamp), + ) + every { MindboxPreferences.firstInitializationTime = any() } just runs + every { MindboxPreferences.versionCode = any() } just runs + + MigrationManager(mockk()).migrateAll() + + verify(exactly = 1) { + MindboxPreferences.firstInitializationTime = expectedTimestamp + .toTimestamp() + .convertToIso8601String() + } + } } diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/utils/TimeProviderTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/utils/TimeProviderTest.kt new file mode 100644 index 000000000..0f16a400d --- /dev/null +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/utils/TimeProviderTest.kt @@ -0,0 +1,73 @@ +package cloud.mindbox.mobile_sdk.utils + +import cloud.mindbox.mobile_sdk.models.Timestamp +import io.mockk.every +import io.mockk.spyk +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test + +class TimeProviderTest { + + private lateinit var timeProvider: SystemTimeProvider + + @Before + fun setup() { + timeProvider = spyk(SystemTimeProvider()) + } + + @Test + fun `elapsedSince returns positive difference when current time is greater`() { + val inputStartTimeMillis = Timestamp(1000L) + val expectedElapsed = 500L + every { timeProvider.currentTimeMillis() } returns 1500L + + val actualElapsed = timeProvider.elapsedSince(inputStartTimeMillis) + + assertEquals(expectedElapsed, actualElapsed.interval) + } + + @Test + fun `elapsedSince returns zero when current time equals start time`() { + val inputStartTimeMillis = Timestamp(1000L) + val expectedElapsed = 0L + every { timeProvider.currentTimeMillis() } returns 1000L + + val actualElapsed = timeProvider.elapsedSince(inputStartTimeMillis) + + assertEquals(expectedElapsed, actualElapsed.interval) + } + + @Test + fun `elapsedSince returns negative value when current time is less than start time`() { + val inputStartTimeMillis = Timestamp(2000L) + val expectedElapsed = -1000L + every { timeProvider.currentTimeMillis() } returns 1000L + + val actualElapsed = timeProvider.elapsedSince(inputStartTimeMillis) + + assertEquals(expectedElapsed, actualElapsed.interval) + } + + @Test + fun `elapsedSince returns correct value when start time is zero`() { + val inputStartTimeMillis = Timestamp(0L) + val expectedElapsed = 5000L + every { timeProvider.currentTimeMillis() } returns 5000L + + val actualElapsed = timeProvider.elapsedSince(inputStartTimeMillis) + + assertEquals(expectedElapsed, actualElapsed.interval) + } + + @Test + fun `elapsedSince returns correct value for large timestamps`() { + val inputStartTimeMillis = Timestamp(1_700_000_000_000L) + val expectedElapsed = 3500L + every { timeProvider.currentTimeMillis() } returns 1_700_000_003_500L + + val actualElapsed = timeProvider.elapsedSince(inputStartTimeMillis) + + assertEquals(expectedElapsed, actualElapsed.interval) + } +} diff --git a/sdk/src/test/resources/ConfigParsing/ConfigWithSettingsABTestsMonitoringInapps.json b/sdk/src/test/resources/ConfigParsing/ConfigWithSettingsABTestsMonitoringInapps.json index 4868abfbc..045b13cac 100644 --- a/sdk/src/test/resources/ConfigParsing/ConfigWithSettingsABTestsMonitoringInapps.json +++ b/sdk/src/test/resources/ConfigParsing/ConfigWithSettingsABTestsMonitoringInapps.json @@ -20,6 +20,10 @@ ], "$type": "and" }, + "tags": { + "layer": "webView", + "type": "modal" + }, "form": { "variants": [ { @@ -330,6 +334,9 @@ "slidingExpiration": { "config": "0.00:30:00", "pushTokenKeepalive": "0.00:40:00" + }, + "featureToggles": { + "shouldSendInAppShowError": true } }, "abtests": [ diff --git a/sdk/src/test/resources/ConfigParsing/Settings/FeatureTogglesConfig.json b/sdk/src/test/resources/ConfigParsing/Settings/FeatureTogglesConfig.json new file mode 100644 index 000000000..69299dac1 --- /dev/null +++ b/sdk/src/test/resources/ConfigParsing/Settings/FeatureTogglesConfig.json @@ -0,0 +1,28 @@ +{ + "operations": { + "viewProduct": { + "systemName": "ViewProduct" + }, + "viewCategory": { + "systemName": "ViewCategory" + }, + "setCart": { + "systemName": "SetCart" + } + }, + "ttl": { + "inapps": "1.00:00:00" + }, + "slidingExpiration": { + "config": "0.00:30:00", + "pushTokenKeepalive": "0.00:40:00" + }, + "inapp": { + "maxInappsPerSession": "2147483647", + "maxInappsPerDay": "33", + "minIntervalBetweenShows": "00:30:00" + }, + "featureToggles": { + "MobileSdkShouldSendInAppShowError": true + } +} diff --git a/sdk/src/test/resources/ConfigParsing/Settings/FeatureTogglesErrors/FeatureTogglesError.json b/sdk/src/test/resources/ConfigParsing/Settings/FeatureTogglesErrors/FeatureTogglesError.json new file mode 100644 index 000000000..ea62a96c9 --- /dev/null +++ b/sdk/src/test/resources/ConfigParsing/Settings/FeatureTogglesErrors/FeatureTogglesError.json @@ -0,0 +1,16 @@ +{ + "operations": { + "viewProduct": { + "systemName": "ViewProduct" + }, + "viewCategory": { + "systemName": "ViewCategory" + }, + "setCart": { + "systemName": "SetCart" + } + }, + "ttl": { + "inapps": "1.00:00:00" + } +} diff --git a/sdk/src/test/resources/ConfigParsing/Settings/FeatureTogglesErrors/FeatureTogglesFalse.json b/sdk/src/test/resources/ConfigParsing/Settings/FeatureTogglesErrors/FeatureTogglesFalse.json new file mode 100644 index 000000000..444d36ee4 --- /dev/null +++ b/sdk/src/test/resources/ConfigParsing/Settings/FeatureTogglesErrors/FeatureTogglesFalse.json @@ -0,0 +1,19 @@ +{ + "operations": { + "viewProduct": { + "systemName": "ViewProduct" + }, + "viewCategory": { + "systemName": "ViewCategory" + }, + "setCart": { + "systemName": "SetCart" + } + }, + "ttl": { + "inapps": "1.00:00:00" + }, + "featureToggles": { + "shouldSendInAppShowError": false + } +} diff --git a/sdk/src/test/resources/ConfigParsing/Settings/FeatureTogglesErrors/FeatureTogglesShouldSendInAppShowErrorMissing.json b/sdk/src/test/resources/ConfigParsing/Settings/FeatureTogglesErrors/FeatureTogglesShouldSendInAppShowErrorMissing.json new file mode 100644 index 000000000..e269393a9 --- /dev/null +++ b/sdk/src/test/resources/ConfigParsing/Settings/FeatureTogglesErrors/FeatureTogglesShouldSendInAppShowErrorMissing.json @@ -0,0 +1,17 @@ +{ + "operations": { + "viewProduct": { + "systemName": "ViewProduct" + }, + "viewCategory": { + "systemName": "ViewCategory" + }, + "setCart": { + "systemName": "SetCart" + } + }, + "ttl": { + "inapps": "1.00:00:00" + }, + "featureToggles": {} +} diff --git a/sdk/src/test/resources/ConfigParsing/Settings/FeatureTogglesErrors/FeatureTogglesShouldSendInAppShowErrorTypeError.json b/sdk/src/test/resources/ConfigParsing/Settings/FeatureTogglesErrors/FeatureTogglesShouldSendInAppShowErrorTypeError.json new file mode 100644 index 000000000..45423cc62 --- /dev/null +++ b/sdk/src/test/resources/ConfigParsing/Settings/FeatureTogglesErrors/FeatureTogglesShouldSendInAppShowErrorTypeError.json @@ -0,0 +1,19 @@ +{ + "operations": { + "viewProduct": { + "systemName": "ViewProduct" + }, + "viewCategory": { + "systemName": "ViewCategory" + }, + "setCart": { + "systemName": "SetCart" + } + }, + "ttl": { + "inapps": "1.00:00:00" + }, + "featureToggles": { + "shouldSendInAppShowError": "true" + } +} diff --git a/sdk/src/test/resources/ConfigParsing/Settings/FeatureTogglesErrors/FeatureTogglesTypeError.json b/sdk/src/test/resources/ConfigParsing/Settings/FeatureTogglesErrors/FeatureTogglesTypeError.json new file mode 100644 index 000000000..45cffd769 --- /dev/null +++ b/sdk/src/test/resources/ConfigParsing/Settings/FeatureTogglesErrors/FeatureTogglesTypeError.json @@ -0,0 +1,17 @@ +{ + "operations": { + "viewProduct": { + "systemName": "ViewProduct" + }, + "viewCategory": { + "systemName": "ViewCategory" + }, + "setCart": { + "systemName": "SetCart" + } + }, + "ttl": { + "inapps": "1.00:00:00" + }, + "featureToggles": "not an object" +} diff --git a/sdk/src/test/resources/ConfigParsing/Settings/SettingsConfig.json b/sdk/src/test/resources/ConfigParsing/Settings/SettingsConfig.json index 8deb8fe5d..69299dac1 100644 --- a/sdk/src/test/resources/ConfigParsing/Settings/SettingsConfig.json +++ b/sdk/src/test/resources/ConfigParsing/Settings/SettingsConfig.json @@ -21,5 +21,8 @@ "maxInappsPerSession": "2147483647", "maxInappsPerDay": "33", "minIntervalBetweenShows": "00:30:00" + }, + "featureToggles": { + "MobileSdkShouldSendInAppShowError": true } }