From 55f1f0587fb98a42d6587578183773118c0dad6d Mon Sep 17 00:00:00 2001 From: Egor Kitselyuk Date: Wed, 11 Mar 2026 17:43:57 +0300 Subject: [PATCH 1/3] MOBILEWEBVIEW-94: Add local state storage --- .../inapp/presentation/view/WebViewAction.kt | 9 ++ .../view/WebViewInappViewHolder.kt | 27 ++++++ .../view/WebViewLocalStateStore.kt | 80 ++++++++++++++++ .../repository/MindboxPreferences.kt | 12 +++ .../view/WebViewLocalStateStoreTest.kt | 96 +++++++++++++++++++ 5 files changed, 224 insertions(+) create mode 100644 sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewLocalStateStore.kt create mode 100644 sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewLocalStateStoreTest.kt diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewAction.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewAction.kt index d9f718f9..e7be5498 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewAction.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewAction.kt @@ -45,6 +45,15 @@ public enum class WebViewAction { @SerializedName("navigationIntercepted") NAVIGATION_INTERCEPTED, + + @SerializedName("localState.get") + LOCAL_STATE_GET, + + @SerializedName("localState.set") + LOCAL_STATE_SET, + + @SerializedName("localState.init") + LOCAL_STATE_INIT, } @InternalMindboxApi 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 1da0b27c..e04e1efe 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 @@ -84,6 +84,9 @@ internal class WebViewInAppViewHolder( private val linkRouter: WebViewLinkRouter by lazy { MindboxWebViewLinkRouter(appContext) } + private val localStateStore: WebViewLocalStateStore by lazy { + WebViewLocalStateStore(appContext) + } override fun bind() {} @@ -132,6 +135,15 @@ internal class WebViewInAppViewHolder( register(WebViewAction.ASYNC_OPERATION, ::handleAsyncOperationAction) register(WebViewAction.OPEN_LINK, ::handleOpenLinkAction) registerSuspend(WebViewAction.SYNC_OPERATION, ::handleSyncOperationAction) + register(WebViewAction.LOCAL_STATE_GET) { message -> + handleLocalStateGetAction(message) + } + register(WebViewAction.LOCAL_STATE_SET) { message -> + handleLocalStateSetAction(message) + } + register(WebViewAction.LOCAL_STATE_INIT) { message -> + handleLocalStateInitAction(message) + } register(WebViewAction.READY) { handleReadyAction( configuration = configuration, @@ -249,6 +261,21 @@ internal class WebViewInAppViewHolder( 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 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) 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 00000000..551dc69f --- /dev/null +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewLocalStateStore.kt @@ -0,0 +1,80 @@ +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 payloadObject: JSONObject = JSONObject(payload) + val jsonData: JSONObject = payloadObject.getJSONObject(FIELD_DATA) + val version: Int = payloadObject.getInt(FIELD_VERSION) + require(version > 0) { "Version must be greater than 0" } + + MindboxPreferences.localStateVersion = version + + return setState(payload = payload) + } + + private fun JSONObject.toMap(): Map { + val keysIterator: Iterator = this.keys() + val resultMap: MutableMap = mutableMapOf() + while (keysIterator.hasNext()) { + val key: String = keysIterator.next() + val value: Any? = this.opt(key) + if (value == null || value == JSONObject.NULL) { + resultMap[key] = null + } else { + resultMap[key] = value.toString() + } + } + return resultMap + } + + 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/repository/MindboxPreferences.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/repository/MindboxPreferences.kt index 8c850015..f3725130 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,8 @@ 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 val prefScope = CoroutineScope(Dispatchers.Default) @@ -252,4 +254,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/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 00000000..20b304e3 --- /dev/null +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewLocalStateStoreTest.kt @@ -0,0 +1,96 @@ +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 `initState stores values and getState returns requested keys with null for missing`() { + store.initState("""{"data":{"key1":"value1","key2":"value2"},"version":2}""") + val actualResponse: JSONObject = store.getState("""{"data":["key1","missing"]}""").toJsonObject() + assertEquals(2, actualResponse.getInt("version")) + val actualData: JSONObject = actualResponse.getJSONObject("data") + assertEquals("value1", actualData.getString("key1")) + assertTrue(actualData.isNull("missing")) + } + + @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")) + } + + private fun String.toJsonObject(): JSONObject = JSONObject(this) +} From c4329765e6b6761675a43050c259c2748110cced Mon Sep 17 00:00:00 2001 From: Egor Kitselyuk Date: Thu, 12 Mar 2026 12:17:51 +0300 Subject: [PATCH 2/3] MOBILEWEBVIEW-94: Add test. Add local state version to ready response --- WebViewLocalStateStorageTests.swift | 279 ++++++++++++++++++ .../inapp/presentation/view/DataCollector.kt | 2 + .../view/WebViewInappViewHolder.kt | 12 +- .../view/WebViewLocalStateStore.kt | 4 +- .../presentation/view/DataCollectorTest.kt | 7 + .../view/WebViewLocalStateStoreTest.kt | 115 +++++++- 6 files changed, 404 insertions(+), 15 deletions(-) create mode 100644 WebViewLocalStateStorageTests.swift diff --git a/WebViewLocalStateStorageTests.swift b/WebViewLocalStateStorageTests.swift new file mode 100644 index 00000000..26b0b44c --- /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/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 index 33df3acd..83a0fb84 100644 --- 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 @@ -31,6 +31,7 @@ internal class DataCollector( 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_IN_APP_ID to Provider.string(inAppId), KEY_INSETS to createInsetsPayload(inAppInsets), @@ -76,6 +77,7 @@ internal class DataCollector( 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" 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 e04e1efe..7132f891 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 @@ -135,15 +135,9 @@ internal class WebViewInAppViewHolder( register(WebViewAction.ASYNC_OPERATION, ::handleAsyncOperationAction) register(WebViewAction.OPEN_LINK, ::handleOpenLinkAction) registerSuspend(WebViewAction.SYNC_OPERATION, ::handleSyncOperationAction) - register(WebViewAction.LOCAL_STATE_GET) { message -> - handleLocalStateGetAction(message) - } - register(WebViewAction.LOCAL_STATE_SET) { message -> - handleLocalStateSetAction(message) - } - register(WebViewAction.LOCAL_STATE_INIT) { message -> - handleLocalStateInitAction(message) - } + registerSuspend(WebViewAction.LOCAL_STATE_GET, ::handleLocalStateGetAction) + registerSuspend(WebViewAction.LOCAL_STATE_SET, ::handleLocalStateSetAction) + registerSuspend(WebViewAction.LOCAL_STATE_INIT, ::handleLocalStateInitAction) register(WebViewAction.READY) { handleReadyAction( configuration = configuration, 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 index 551dc69f..afdc3a04 100644 --- 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 @@ -46,9 +46,7 @@ internal class WebViewLocalStateStore( } fun initState(payload: String): String { - val payloadObject: JSONObject = JSONObject(payload) - val jsonData: JSONObject = payloadObject.getJSONObject(FIELD_DATA) - val version: Int = payloadObject.getInt(FIELD_VERSION) + val version: Int = JSONObject(payload).getInt(FIELD_VERSION) require(version > 0) { "Version must be greater than 0" } MindboxPreferences.localStateVersion = version 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 index 2962012e..9c09a6da 100644 --- 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 @@ -48,6 +48,7 @@ class DataCollectorTest { every { resources.configuration } returns uiConfiguration every { resources.displayMetrics } returns displayMetrics mockkObject(MindboxPreferences) + every { MindboxPreferences.localStateVersion } returns 1 } @After @@ -61,6 +62,7 @@ class DataCollectorTest { 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.userVisitCount } returns 7 every { permissionManager.getCameraPermissionStatus() } returns PermissionStatus.GRANTED every { permissionManager.getLocationPermissionStatus() } returns PermissionStatus.DENIED @@ -97,6 +99,7 @@ class DataCollectorTest { 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) @@ -121,6 +124,7 @@ class DataCollectorTest { Locale.setDefault(Locale.forLanguageTag("ru-RU")) uiConfiguration.uiMode = UiConfiguration.UI_MODE_NIGHT_YES every { MindboxPreferences.deviceUuid } returns "" + every { MindboxPreferences.localStateVersion } returns 3 every { MindboxPreferences.userVisitCount } returns 3 every { permissionManager.getCameraPermissionStatus() } returns PermissionStatus.GRANTED every { permissionManager.getLocationPermissionStatus() } returns PermissionStatus.GRANTED @@ -152,6 +156,7 @@ class DataCollectorTest { 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) @@ -170,6 +175,7 @@ class DataCollectorTest { 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.userVisitCount } returns 0 every { permissionManager.getCameraPermissionStatus() } returns PermissionStatus.DENIED every { permissionManager.getLocationPermissionStatus() } returns PermissionStatus.DENIED @@ -197,6 +203,7 @@ class DataCollectorTest { 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 { 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 index 20b304e3..9ece5c5f 100644 --- 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 @@ -39,13 +39,30 @@ internal class WebViewLocalStateStoreTest { } @Test - fun `initState stores values and getState returns requested keys with null for missing`() { + 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","missing"]}""").toJsonObject() + val actualResponse: JSONObject = store.getState("""{"data":["key1"]}""").toJsonObject() assertEquals(2, actualResponse.getInt("version")) val actualData: JSONObject = actualResponse.getJSONObject("data") assertEquals("value1", actualData.getString("key1")) - assertTrue(actualData.isNull("missing")) + 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 @@ -92,5 +109,97 @@ internal class WebViewLocalStateStoreTest { 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) } From 331229e0126b124aaa7b8bec0b4d0b4979904836 Mon Sep 17 00:00:00 2001 From: Egor Kitselyuk Date: Thu, 12 Mar 2026 14:51:31 +0300 Subject: [PATCH 3/3] MOBILEWEBVIEW-94: Follow code review --- .../presentation/view/WebViewLocalStateStore.kt | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) 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 index afdc3a04..cc9f179d 100644 --- 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 @@ -55,18 +55,12 @@ internal class WebViewLocalStateStore( } private fun JSONObject.toMap(): Map { - val keysIterator: Iterator = this.keys() - val resultMap: MutableMap = mutableMapOf() - while (keysIterator.hasNext()) { - val key: String = keysIterator.next() - val value: Any? = this.opt(key) - if (value == null || value == JSONObject.NULL) { - resultMap[key] = null - } else { - resultMap[key] = value.toString() + 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()) } } - return resultMap } private fun buildResponse(data: Map): String {