Skip to content

Commit 8f5fd74

Browse files
authored
Merge pull request #700 from mindbox-cloud/mission/webview-inapp
Merge mission inapp webview branch
2 parents fccc1d5 + 0fee705 commit 8f5fd74

142 files changed

Lines changed: 9063 additions & 947 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
1+
//
2+
// WebViewLocalStateStorageTests.swift
3+
// MindboxTests
4+
//
5+
// Created by Sergei Semko on 3/11/26.
6+
// Copyright © 2026 Mindbox. All rights reserved.
7+
//
8+
9+
import Testing
10+
@testable import Mindbox
11+
12+
@Suite("WebViewLocalStateStorage", .tags(.webView))
13+
struct WebViewLocalStateStorageTests {
14+
15+
private let testSuiteName = "cloud.Mindbox.test.webview.localState"
16+
private let keyPrefix = Constants.WebViewLocalState.keyPrefix
17+
18+
private func makeSUT() -> (sut: WebViewLocalStateStorage, defaults: UserDefaults, persistence: MockPersistenceStorage) {
19+
let persistence = MockPersistenceStorage()
20+
let defaults = UserDefaults(suiteName: testSuiteName)!
21+
defaults.removePersistentDomain(forName: testSuiteName)
22+
let sut = WebViewLocalStateStorage(dataDefaults: defaults, persistenceStorage: persistence)
23+
return (sut, defaults, persistence)
24+
}
25+
26+
// MARK: - get
27+
28+
@Test("get returns default version and empty data when storage is empty")
29+
func getEmptyStorage() {
30+
let (sut, _, _) = makeSUT()
31+
32+
let state = sut.get(keys: [])
33+
34+
#expect(state.version == Constants.WebViewLocalState.defaultVersion)
35+
#expect(state.data.isEmpty)
36+
}
37+
38+
@Test("get returns all stored keys when keys array is empty")
39+
func getAllKeys() {
40+
let (sut, defaults, _) = makeSUT()
41+
defaults.set("value1", forKey: "\(keyPrefix)key1")
42+
defaults.set("value2", forKey: "\(keyPrefix)key2")
43+
44+
let state = sut.get(keys: [])
45+
46+
#expect(state.data.count == 2)
47+
#expect(state.data["key1"] == "value1")
48+
#expect(state.data["key2"] == "value2")
49+
}
50+
51+
@Test("get returns only requested keys")
52+
func getSpecificKeys() {
53+
let (sut, defaults, _) = makeSUT()
54+
defaults.set("value1", forKey: "\(keyPrefix)key1")
55+
defaults.set("value2", forKey: "\(keyPrefix)key2")
56+
defaults.set("value3", forKey: "\(keyPrefix)key3")
57+
58+
let state = sut.get(keys: ["key1", "key3"])
59+
60+
#expect(state.data.count == 2)
61+
#expect(state.data["key1"] == "value1")
62+
#expect(state.data["key3"] == "value3")
63+
}
64+
65+
@Test("get omits missing keys from data")
66+
func getMissingKeys() {
67+
let (sut, defaults, _) = makeSUT()
68+
defaults.set("value1", forKey: "\(keyPrefix)key1")
69+
70+
let state = sut.get(keys: ["key1", "missing"])
71+
72+
#expect(state.data.count == 1)
73+
#expect(state.data["key1"] == "value1")
74+
#expect(state.data["missing"] == nil)
75+
}
76+
77+
@Test("get returns current version from persistence")
78+
func getCurrentVersion() {
79+
let (sut, _, persistence) = makeSUT()
80+
persistence.webViewLocalStateVersion = 5
81+
82+
let state = sut.get(keys: [])
83+
84+
#expect(state.version == 5)
85+
}
86+
87+
@Test("get returns default version when persistence version is nil")
88+
func getDefaultVersion() {
89+
let (sut, _, persistence) = makeSUT()
90+
persistence.webViewLocalStateVersion = nil
91+
92+
let state = sut.get(keys: [])
93+
94+
#expect(state.version == Constants.WebViewLocalState.defaultVersion)
95+
}
96+
97+
// MARK: - set
98+
99+
@Test("set stores values in UserDefaults")
100+
func setStoresValues() {
101+
let (sut, defaults, _) = makeSUT()
102+
103+
_ = sut.set(data: ["key1": "value1", "key2": "value2"])
104+
105+
#expect(defaults.string(forKey: "\(keyPrefix)key1") == "value1")
106+
#expect(defaults.string(forKey: "\(keyPrefix)key2") == "value2")
107+
}
108+
109+
@Test("set removes key when value is nil")
110+
func setRemovesNilKey() {
111+
let (sut, defaults, _) = makeSUT()
112+
defaults.set("value1", forKey: "\(keyPrefix)key1")
113+
114+
_ = sut.set(data: ["key1": nil])
115+
116+
#expect(defaults.string(forKey: "\(keyPrefix)key1") == nil)
117+
}
118+
119+
@Test("set updates existing values")
120+
func setUpdatesValues() {
121+
let (sut, defaults, _) = makeSUT()
122+
defaults.set("old", forKey: "\(keyPrefix)key1")
123+
124+
let state = sut.set(data: ["key1": "new"])
125+
126+
#expect(defaults.string(forKey: "\(keyPrefix)key1") == "new")
127+
#expect(state.data["key1"] == "new")
128+
}
129+
130+
@Test("set returns only affected keys")
131+
func setReturnsAffectedKeys() {
132+
let (sut, defaults, _) = makeSUT()
133+
defaults.set("existing", forKey: "\(keyPrefix)existing")
134+
135+
let state = sut.set(data: ["key1": "value1"])
136+
137+
#expect(state.data.count == 1)
138+
#expect(state.data["key1"] == "value1")
139+
#expect(state.data["existing"] == nil)
140+
}
141+
142+
@Test("set does not change version")
143+
func setPreservesVersion() {
144+
let (sut, _, persistence) = makeSUT()
145+
persistence.webViewLocalStateVersion = 3
146+
147+
let state = sut.set(data: ["key1": "value1"])
148+
149+
#expect(state.version == 3)
150+
#expect(persistence.webViewLocalStateVersion == 3)
151+
}
152+
153+
@Test("set stores each key as separate UserDefaults entry")
154+
func setSeparateEntries() {
155+
let (sut, defaults, _) = makeSUT()
156+
157+
_ = sut.set(data: ["firstKey": "firstValue", "secondKey": "secondValue"])
158+
159+
#expect(defaults.string(forKey: "\(keyPrefix)firstKey") == "firstValue")
160+
#expect(defaults.string(forKey: "\(keyPrefix)secondKey") == "secondValue")
161+
}
162+
163+
// MARK: - initialize
164+
165+
@Test("initialize stores version in PersistenceStorage")
166+
func initStoresVersion() {
167+
let (sut, _, persistence) = makeSUT()
168+
169+
_ = sut.initialize(version: 7, data: ["key": "value"])
170+
171+
#expect(persistence.webViewLocalStateVersion == 7)
172+
}
173+
174+
@Test("initialize stores data and returns it")
175+
func initStoresAndReturnsData() throws {
176+
let (sut, defaults, _) = makeSUT()
177+
178+
let state = try #require(sut.initialize(version: 2, data: ["key1": "value1", "key2": "value2"]))
179+
180+
#expect(state.version == 2)
181+
#expect(state.data["key1"] == "value1")
182+
#expect(state.data["key2"] == "value2")
183+
#expect(defaults.string(forKey: "\(keyPrefix)key1") == "value1")
184+
#expect(defaults.string(forKey: "\(keyPrefix)key2") == "value2")
185+
}
186+
187+
@Test("initialize rejects zero version")
188+
func initRejectsZero() {
189+
let (sut, _, _) = makeSUT()
190+
191+
#expect(sut.initialize(version: 0, data: ["key": "value"]) == nil)
192+
}
193+
194+
@Test("initialize rejects negative version")
195+
func initRejectsNegative() {
196+
let (sut, _, _) = makeSUT()
197+
198+
#expect(sut.initialize(version: -1, data: ["key": "value"]) == nil)
199+
}
200+
201+
@Test("initialize removes keys with nil values")
202+
func initRemovesNilKeys() {
203+
let (sut, defaults, _) = makeSUT()
204+
defaults.set("value1", forKey: "\(keyPrefix)key1")
205+
206+
let state = sut.initialize(version: 2, data: ["key1": nil])
207+
208+
#expect(state != nil)
209+
#expect(defaults.string(forKey: "\(keyPrefix)key1") == nil)
210+
}
211+
212+
@Test("initialize merges with existing data")
213+
func initMergesData() {
214+
let (sut, defaults, _) = makeSUT()
215+
defaults.set("existing", forKey: "\(keyPrefix)old")
216+
217+
let state = sut.initialize(version: 3, data: ["new": "value"])
218+
219+
#expect(state != nil)
220+
#expect(defaults.string(forKey: "\(keyPrefix)old") == "existing")
221+
#expect(defaults.string(forKey: "\(keyPrefix)new") == "value")
222+
}
223+
224+
@Test("initialize does not store version on rejection")
225+
func initPreservesVersionOnReject() {
226+
let (sut, _, persistence) = makeSUT()
227+
persistence.webViewLocalStateVersion = 5
228+
229+
_ = sut.initialize(version: 0, data: ["key": "value"])
230+
231+
#expect(persistence.webViewLocalStateVersion == 5)
232+
}
233+
234+
// MARK: - Integration
235+
236+
@Test("full flow: init → set → get")
237+
func fullFlow() throws {
238+
let (sut, _, _) = makeSUT()
239+
240+
let initState = try #require(sut.initialize(version: 2, data: ["key1": "value1", "key2": "value2"]))
241+
#expect(initState.version == 2)
242+
243+
let setState = sut.set(data: ["key1": "updated", "key2": nil, "key3": "value3"])
244+
#expect(setState.version == 2)
245+
246+
let getState = sut.get(keys: [])
247+
#expect(getState.version == 2)
248+
#expect(getState.data["key1"] == "updated")
249+
#expect(getState.data["key2"] == nil)
250+
#expect(getState.data["key3"] == "value3")
251+
}
252+
253+
@Test("get after set with null returns empty for deleted key")
254+
func setNullThenGet() {
255+
let (sut, _, _) = makeSUT()
256+
257+
_ = sut.set(data: ["key1": "value1"])
258+
_ = sut.set(data: ["key1": nil])
259+
260+
let state = sut.get(keys: ["key1"])
261+
#expect(state.data.isEmpty)
262+
}
263+
264+
@Test("prefix isolation: non-prefixed keys and Apple system keys are filtered out")
265+
func prefixIsolation() {
266+
let (sut, defaults, _) = makeSUT()
267+
defaults.set("foreign", forKey: "foreignKey")
268+
defaults.set("value", forKey: "\(keyPrefix)myKey")
269+
270+
let state = sut.get(keys: [])
271+
272+
#expect(state.data.count == 1)
273+
#expect(state.data["myKey"] == "value")
274+
#expect(state.data["foreignKey"] == nil)
275+
#expect(state.data["AKLastLocale"] == nil)
276+
#expect(state.data["AppleLocale"] == nil)
277+
#expect(state.data["NSInterfaceStyle"] == nil)
278+
}
279+
}

example/app/build.gradle

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ dependencies {
9292
implementation 'com.google.code.gson:gson:2.11.0'
9393

9494
//Mindbox
95-
implementation 'cloud.mindbox:mobile-sdk:2.14.5'
95+
implementation 'cloud.mindbox:mobile-sdk:2.15.0-rc'
9696
implementation 'cloud.mindbox:mindbox-firebase'
9797
implementation 'cloud.mindbox:mindbox-huawei'
9898
implementation 'cloud.mindbox:mindbox-rustore'

gradle.properties

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ android.enableJetifier=true
2020
# Kotlin code style for this project: "official" or "obsolete":
2121
kotlin.code.style=official
2222
# SDK version property
23-
SDK_VERSION_NAME=2.14.5
23+
SDK_VERSION_NAME=2.15.0-rc
2424
USE_LOCAL_MINDBOX_COMMON=true
2525
android.nonTransitiveRClass=false
2626
kotlin.mpp.androidGradlePluginCompatibility.nowarn=true

sdk/src/main/AndroidManifest.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
66
<uses-permission android:name="android.permission.INTERNET" />
77
<uses-permission android:name="com.google.android.gms.permission.AD_ID"/>
8+
<uses-permission android:name="android.permission.VIBRATE" />
89

910
<uses-sdk tools:overrideLibrary="io.mockk, io.mockk.proxy.android" />
1011
<application>

sdk/src/main/java/cloud/mindbox/mobile_sdk/Extensions.kt

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import cloud.mindbox.mobile_sdk.Mindbox.logE
2222
import cloud.mindbox.mobile_sdk.Mindbox.logW
2323
import cloud.mindbox.mobile_sdk.inapp.domain.models.InApp
2424
import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppType
25+
import cloud.mindbox.mobile_sdk.inapp.domain.models.Layer
2526
import cloud.mindbox.mobile_sdk.logger.MindboxLoggerImpl
2627
import cloud.mindbox.mobile_sdk.pushes.PushNotificationManager.EXTRA_UNIQ_PUSH_BUTTON_KEY
2728
import cloud.mindbox.mobile_sdk.pushes.PushNotificationManager.EXTRA_UNIQ_PUSH_KEY
@@ -299,3 +300,19 @@ internal fun List<InApp>.sortByPriority(): List<InApp> {
299300
internal inline fun <T> Queue<T>.pollIf(predicate: (T) -> Boolean): T? {
300301
return peek()?.takeIf(predicate)?.let { poll() }
301302
}
303+
304+
internal fun InAppType.getImageUrl(): String? {
305+
return when (this) {
306+
is InAppType.WebView -> this.layers
307+
is InAppType.ModalWindow -> this.layers
308+
is InAppType.Snackbar -> this.layers
309+
}
310+
.filterIsInstance<Layer.ImageLayer>()
311+
.firstOrNull()
312+
?.source
313+
?.let { source ->
314+
when (source) {
315+
is Layer.ImageLayer.Source.UrlSource -> source.url
316+
}
317+
}
318+
}

sdk/src/main/java/cloud/mindbox/mobile_sdk/Mindbox.kt

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ public object Mindbox : MindboxLog {
9494
private lateinit var lifecycleManager: LifecycleManager
9595

9696
private val userVisitManager: UserVisitManager by mindboxInject { userVisitManager }
97+
private val timeProvider by mindboxInject { timeProvider }
9798

9899
internal var pushServiceHandlers: List<PushServiceHandler> = listOf()
99100

@@ -1244,6 +1245,11 @@ public object Mindbox : MindboxLog {
12441245
MindboxPreferences.isNotificationEnabled = isNotificationEnabled
12451246
MindboxPreferences.instanceId = instanceId
12461247

1248+
if (MindboxPreferences.firstInitializationTime == null) {
1249+
MindboxPreferences.firstInitializationTime = timeProvider.currentTimestamp()
1250+
.convertToIso8601String()
1251+
}
1252+
12471253
MindboxEventManager.appInstalled(context, initData, configuration.shouldCreateCustomer)
12481254

12491255
deliverDeviceUuid(deviceUuid)
@@ -1358,7 +1364,9 @@ public object Mindbox : MindboxLog {
13581364
requestUrl = requestUrl,
13591365
sdkVersionNumeric = Constants.SDK_VERSION_NUMERIC
13601366
)
1361-
1367+
if (source != null || requestUrl != null) {
1368+
sessionStorageManager.lastTrackVisitData = trackVisitData
1369+
}
13621370
MindboxEventManager.appStarted(applicationContext, trackVisitData)
13631371
}
13641372
}

0 commit comments

Comments
 (0)