Skip to content

Commit d369165

Browse files
authored
Merge pull request #852 from qonversion/kamo/dev-1232-android-sdk-zalipanie-issendingscheduled-posle-force-flasha
fix: unstick property sending flag + invalidate named-key RC cache on attach/detach (DEV-1232)
2 parents a5370d7 + f82c58e commit d369165

6 files changed

Lines changed: 247 additions & 15 deletions

File tree

‎sdk/src/main/java/com/qonversion/android/sdk/internal/QRemoteConfigManager.kt‎

Lines changed: 32 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,12 @@ internal class QRemoteConfigManager @Inject constructor(
4444
lateinit var userPropertiesManager: QUserPropertiesManager
4545
private val mainHandler = Handler(Looper.getMainLooper())
4646

47+
// Bumped on every cache invalidation (attach/detach, user change). Loads
48+
// capture it when they start and skip the cache write if it moved — an
49+
// in-flight response evaluated before the invalidating event must not be
50+
// re-cached as fresh. Main-thread confined like the rest of the state.
51+
private var invalidationGeneration = 0
52+
4753
fun handlePendingRequests() = postToMainThread {
4854
loadingStates.filter { it.value.callbacks.isNotEmpty() }
4955
.keys.forEach { contextKey -> loadRemoteConfig(contextKey, null) }
@@ -74,6 +80,7 @@ internal class QRemoteConfigManager @Inject constructor(
7480
}
7581

7682
fun onUserUpdate() = postToMainThread {
83+
invalidationGeneration++
7784
loadingStates = mutableMapOf()
7885
}
7986

@@ -99,12 +106,15 @@ internal class QRemoteConfigManager @Inject constructor(
99106

100107
loadingState.isInProgress = true
101108
loadingState.loadedConfig = null
109+
val generationAtStart = invalidationGeneration
102110

103111
userPropertiesManager.forceSendProperties(object : QonversionEmptyCallback {
104112
override fun onComplete() {
105113
remoteConfigService.loadRemoteConfig(contextKey, object : QonversionRemoteConfigCallback {
106114
override fun onSuccess(remoteConfig: QRemoteConfig) {
107-
loadingState.loadedConfig = remoteConfig
115+
if (invalidationGeneration == generationAtStart) {
116+
loadingState.loadedConfig = remoteConfig
117+
}
108118
fireToCallbacks(contextKey) { onSuccess(remoteConfig) }
109119
}
110120

@@ -176,31 +186,40 @@ internal class QRemoteConfigManager @Inject constructor(
176186
}
177187

178188
fun attachUserToExperiment(experimentId: String, groupId: String, callback: QonversionExperimentAttachCallback) = postToMainThread {
179-
loadingStates[EmptyContextKey]?.loadedConfig = null
189+
invalidateLoadedConfigs()
180190
remoteConfigService.attachUserToExperiment(experimentId, groupId, callback)
181191
}
182192

183193
fun detachUserFromExperiment(experimentId: String, callback: QonversionExperimentAttachCallback) = postToMainThread {
184-
loadingStates[EmptyContextKey]?.loadedConfig = null
194+
invalidateLoadedConfigs()
185195
remoteConfigService.detachUserFromExperiment(experimentId, callback)
186196
}
187197

188198
fun attachUserToRemoteConfiguration(
189199
remoteConfigurationId: String,
190200
callback: QonversionRemoteConfigurationAttachCallback
191201
) = postToMainThread {
192-
loadingStates[EmptyContextKey]?.loadedConfig = null
202+
invalidateLoadedConfigs()
193203
remoteConfigService.attachUserToRemoteConfiguration(remoteConfigurationId, callback)
194204
}
195205

196206
fun detachUserFromRemoteConfiguration(
197207
remoteConfigurationId: String,
198208
callback: QonversionRemoteConfigurationAttachCallback
199209
) = postToMainThread {
200-
loadingStates[EmptyContextKey]?.loadedConfig = null
210+
invalidateLoadedConfigs()
201211
remoteConfigService.detachUserFromRemoteConfiguration(remoteConfigurationId, callback)
202212
}
203213

214+
// An attach/detach is addressed by experiment/configuration id, and the SDK does not
215+
// know which context key that entity serves — drop every cached config, not just the
216+
// empty-key one, or configs under named context keys stay stale until process restart.
217+
// The generation bump also stops in-flight loads from re-caching a pre-attach response.
218+
private fun invalidateLoadedConfigs() {
219+
invalidationGeneration++
220+
loadingStates.values.forEach { it.loadedConfig = null }
221+
}
222+
204223
private fun getRemoteConfigListCallbackWrapper(
205224
contextKeys: List<String>?,
206225
includeEmptyContextKey: Boolean,
@@ -209,13 +228,16 @@ internal class QRemoteConfigManager @Inject constructor(
209228
// Remembering loading states for the case of user change -
210229
// if it happens, we won't store remote configs for different user.
211230
val localLoadingStates = loadingStates
231+
val generationAtStart = invalidationGeneration
212232
return object : QonversionRemoteConfigListCallback {
213233
override fun onSuccess(remoteConfigList: QRemoteConfigList) {
214-
remoteConfigList.remoteConfigs.forEach { remoteConfig ->
215-
val contextKey = remoteConfig.source.contextKey
216-
val loadingState = localLoadingStates[contextKey] ?: LoadingState()
217-
loadingState.loadedConfig = remoteConfig
218-
localLoadingStates[contextKey] = loadingState
234+
if (invalidationGeneration == generationAtStart) {
235+
remoteConfigList.remoteConfigs.forEach { remoteConfig ->
236+
val contextKey = remoteConfig.source.contextKey
237+
val loadingState = localLoadingStates[contextKey] ?: LoadingState()
238+
loadingState.loadedConfig = remoteConfig
239+
localLoadingStates[contextKey] = loadingState
240+
}
219241
}
220242

221243
callback.onSuccess(remoteConfigList)

‎sdk/src/main/java/com/qonversion/android/sdk/internal/QUserPropertiesManager.kt‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,11 @@ internal class QUserPropertiesManager @Inject internal constructor(
3030
internal var productCenterManager: QProductCenterManager? = null
3131
private var handler: Handler? = null
3232
private var isRequestInProgress = false
33+
34+
// Semantics: "a send is known to be scheduled or will be re-scheduled".
35+
// false while a delayed job is still pending is a tolerated state (it only
36+
// causes an extra no-op wakeup, never a missed send) — do not "fix" it by
37+
// resetting the flag exclusively from the scheduled job.
3338
private var isSendingScheduled = false
3439
private var retryDelay = PROPERTY_UPLOAD_MIN_DELAY
3540
private var retriesCounter = 0
@@ -72,6 +77,11 @@ internal class QUserPropertiesManager @Inject internal constructor(
7277

7378
public fun forceSendProperties(callback: QonversionEmptyCallback? = null) {
7479
if (isRequestInProgress) {
80+
// The scheduled job that lands here is consumed without sending.
81+
// Drop the flag so subsequent setters can schedule a new send —
82+
// otherwise properties set during an in-flight request hang until
83+
// the next background/RC trigger.
84+
isSendingScheduled = false
7585
if (callback != null) {
7686
completions.add(callback)
7787
}
@@ -101,6 +111,15 @@ internal class QUserPropertiesManager @Inject internal constructor(
101111

102112
// Cleaning all the properties (not only succeeded) as we don't want to resend invalid ones again
103113
propertiesStorage.clear(properties)
114+
115+
// Properties stored or overwritten while the request was in
116+
// flight are not part of the sent snapshot — schedule a
117+
// follow-up send for them (the clear above is
118+
// value-conditional, so overwrites survive it). In background
119+
// the schedule is skipped and onAppForeground picks them up.
120+
if (propertiesStorage.getProperties().isNotEmpty() && !isSendingScheduled) {
121+
sendPropertiesWithDelay(retryDelay)
122+
}
104123
},
105124
onError = {
106125
fireCallbacks()
@@ -124,6 +143,8 @@ internal class QUserPropertiesManager @Inject internal constructor(
124143
}
125144
})
126145
} else {
146+
// Nothing to send — the pending schedule (if any) is satisfied.
147+
isSendingScheduled = false
127148
callback?.onComplete()
128149
}
129150
}

‎sdk/src/main/java/com/qonversion/android/sdk/internal/storage/UserPropertiesStorage.kt‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,20 @@ package com.qonversion.android.sdk.internal.storage
33
import java.util.concurrent.ConcurrentHashMap
44

55
internal class UserPropertiesStorage : PropertiesStorage {
6-
private val userProperties: MutableMap<String, String> =
6+
private val userProperties: ConcurrentHashMap<String, String> =
77
ConcurrentHashMap()
88

99
override fun save(key: String, value: String) {
1010
userProperties[key] = value
1111
}
1212

1313
override fun clear(properties: Map<String, String>) {
14-
properties.keys.map {
15-
userProperties.remove(it)
14+
// Value-conditional removal: a key overwritten while its previous value
15+
// was in flight must survive the post-send cleanup, or the new value
16+
// would be silently lost. Unchanged values are still removed so invalid
17+
// ones are not resent.
18+
properties.forEach { (key, value) ->
19+
userProperties.remove(key, value)
1620
}
1721
}
1822

‎sdk/src/test/java/com/qonversion/android/sdk/internal/QRemoteConfigManagerTest.kt‎

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,14 @@ import com.qonversion.android.sdk.internal.services.QFallbacksService
1212
import com.qonversion.android.sdk.internal.services.QRemoteConfigService
1313
import com.qonversion.android.sdk.listeners.QonversionRemoteConfigCallback
1414
import com.qonversion.android.sdk.listeners.QonversionRemoteConfigListCallback
15+
import com.qonversion.android.sdk.listeners.QonversionEmptyCallback
1516
import io.mockk.Called
1617
import io.mockk.clearAllMocks
18+
import io.mockk.every
19+
import io.mockk.just
1720
import io.mockk.mockk
21+
import io.mockk.runs
22+
import io.mockk.slot
1823
import io.mockk.verify
1924
import org.junit.Assert.assertEquals
2025
import org.junit.Assert.assertTrue
@@ -256,6 +261,95 @@ internal class QRemoteConfigManagerTest {
256261
verify { mockRemoteConfigService wasNot Called }
257262
}
258263

264+
@Test
265+
fun `every attach and detach entry point invalidates named-key cached configs`() {
266+
// given - all four entry points are addressed by entity id only, so the
267+
// SDK cannot know which context key is served and must drop every cache
268+
userStateProvider.stable = true
269+
val entryPoints = listOf<Pair<String, (QRemoteConfigManager) -> Unit>>(
270+
"attachUserToRemoteConfiguration" to { it.attachUserToRemoteConfiguration("config_id", mockk(relaxed = true)) },
271+
"detachUserFromRemoteConfiguration" to { it.detachUserFromRemoteConfiguration("config_id", mockk(relaxed = true)) },
272+
"attachUserToExperiment" to { it.attachUserToExperiment("experiment_id", "group_id", mockk(relaxed = true)) },
273+
"detachUserFromExperiment" to { it.detachUserFromExperiment("experiment_id", mockk(relaxed = true)) },
274+
)
275+
276+
entryPoints.forEach { (name, entryPoint) ->
277+
// given - cached configs under the empty AND a named context key,
278+
// plus a pending callback that must survive the invalidation
279+
val pendingCallback = mockk<QonversionRemoteConfigCallback>(relaxed = true)
280+
loadingStates()[null] = QRemoteConfigManager.LoadingState(loadedConfig = mockk<QRemoteConfig>(relaxed = true))
281+
loadingStates()["ctx"] = QRemoteConfigManager.LoadingState(
282+
loadedConfig = mockk<QRemoteConfig>(relaxed = true),
283+
callbacks = mutableListOf(pendingCallback),
284+
)
285+
286+
// when
287+
entryPoint(manager)
288+
shadowOf(Looper.getMainLooper()).idle()
289+
290+
// then - every cached config is dropped, but loading states and
291+
// their pending callbacks are preserved (non-destructive invalidation)
292+
assertEquals("$name must drop the empty-key config", null, loadingStates()[null]?.loadedConfig)
293+
assertEquals("$name must drop the named-key config", null, loadingStates()["ctx"]?.loadedConfig)
294+
assertEquals("$name must preserve pending callbacks", 1, loadingStates()["ctx"]?.callbacks?.size)
295+
}
296+
}
297+
298+
@Test
299+
fun `attach invalidation prevents an in-flight load from re-caching a stale config`() {
300+
// given - a load is in flight when the attach lands
301+
userStateProvider.stable = true
302+
val loadCallback = mockk<QonversionRemoteConfigCallback>(relaxed = true)
303+
val serviceCallback = slot<QonversionRemoteConfigCallback>()
304+
every { mockRemoteConfigService.loadRemoteConfig("ctx", capture(serviceCallback)) } just runs
305+
every { mockUserPropertiesManager.forceSendProperties(any()) } answers {
306+
firstArg<QonversionEmptyCallback?>()?.onComplete()
307+
}
308+
309+
manager.loadRemoteConfig("ctx", loadCallback)
310+
shadowOf(Looper.getMainLooper()).idle()
311+
312+
// when - the attach invalidates mid-flight, then the pre-attach response lands
313+
manager.attachUserToRemoteConfiguration("config_id", mockk(relaxed = true))
314+
shadowOf(Looper.getMainLooper()).idle()
315+
val staleConfig = mockk<QRemoteConfig>(relaxed = true)
316+
serviceCallback.captured.onSuccess(staleConfig)
317+
318+
// then - the stale (pre-attach) evaluation must not be re-cached, but
319+
// the response is still DELIVERED to the waiting caller and the state
320+
// is left refetchable (the guard skips only the cache write)
321+
assertEquals(null, loadingStates()["ctx"]?.loadedConfig)
322+
verify(exactly = 1) { loadCallback.onSuccess(staleConfig) }
323+
assertEquals(false, loadingStates()["ctx"]?.isInProgress)
324+
}
325+
326+
@Test
327+
fun `attach invalidation prevents an in-flight list load from re-caching stale configs`() {
328+
// given - a list load is in flight when the attach lands (the map is
329+
// NOT replaced on attach, so the generation guard is the only barrier)
330+
userStateProvider.stable = true
331+
val listServiceCallback = slot<QonversionRemoteConfigListCallback>()
332+
every {
333+
mockRemoteConfigService.loadRemoteConfigs(listOf("ctx"), false, capture(listServiceCallback))
334+
} just runs
335+
every { mockUserPropertiesManager.forceSendProperties(any()) } answers {
336+
firstArg<QonversionEmptyCallback?>()?.onComplete()
337+
}
338+
339+
manager.loadRemoteConfigList(listOf("ctx"), false, mockk(relaxed = true))
340+
shadowOf(Looper.getMainLooper()).idle()
341+
342+
// when - the attach invalidates mid-flight, then the pre-attach list lands
343+
manager.attachUserToRemoteConfiguration("config_id", mockk(relaxed = true))
344+
shadowOf(Looper.getMainLooper()).idle()
345+
val staleConfig = mockk<QRemoteConfig>(relaxed = true)
346+
every { staleConfig.source.contextKey } returns "ctx"
347+
listServiceCallback.captured.onSuccess(QRemoteConfigList(listOf(staleConfig)))
348+
349+
// then - nothing from the stale list is cached
350+
assertEquals(null, loadingStates()["ctx"]?.loadedConfig)
351+
}
352+
259353
private fun listRequests() =
260354
manager.getPrivateField<List<*>>("listRequests")
261355

‎sdk/src/test/java/com/qonversion/android/sdk/internal/QUserPropertiesManagerTest.kt‎

Lines changed: 80 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -267,8 +267,10 @@ internal class QUserPropertiesManagerTest {
267267

268268
@Test
269269
fun `should force send properties and get response in onSuccess callback`() {
270-
// given
271-
mockPropertiesStorage(properties)
270+
// given - the storage is drained by the send, so the post-clear check sees no leftovers
271+
every {
272+
mockPropertiesStorage.getProperties()
273+
} returnsMany listOf(properties, emptyMap())
272274

273275
val propertiesResult = SendPropertiesResult(
274276
emptyList(),
@@ -289,6 +291,7 @@ internal class QUserPropertiesManagerTest {
289291
mockPropertiesStorage.getProperties()
290292
mockRepository.sendProperties(properties, any(), any())
291293
mockPropertiesStorage.clear(properties)
294+
mockPropertiesStorage.getProperties()
292295
}
293296

294297
val isRequestInProgress =
@@ -306,6 +309,81 @@ internal class QUserPropertiesManagerTest {
306309
)
307310
}
308311

312+
@Test
313+
fun `should reset isSendingScheduled when request is in progress`() {
314+
// given - a scheduled job fires while a request is still in flight
315+
propertiesManager.mockPrivateField(fieldIsRequestInProgress, true)
316+
propertiesManager.mockPrivateField(fieldIsSendingScheduled, true)
317+
318+
// when
319+
propertiesManager.forceSendProperties()
320+
321+
// then - the flag is dropped so later setters can schedule a new send
322+
val isSendingScheduled = propertiesManager.getPrivateField<Boolean>(fieldIsSendingScheduled)
323+
assertEquals(false, isSendingScheduled)
324+
}
325+
326+
@Test
327+
fun `should reset isSendingScheduled when properties storage is empty`() {
328+
// given - the scheduled send finds nothing to deliver
329+
propertiesManager.mockPrivateField(fieldIsSendingScheduled, true)
330+
mockPropertiesStorage(mapOf())
331+
332+
// when
333+
propertiesManager.forceSendProperties()
334+
335+
// then
336+
val isSendingScheduled = propertiesManager.getPrivateField<Boolean>(fieldIsSendingScheduled)
337+
assertEquals(false, isSendingScheduled)
338+
}
339+
340+
@Test
341+
fun `should not schedule follow-up send when sending is already scheduled`() {
342+
// given - a setter scheduled a send while the request was in flight
343+
every {
344+
mockPropertiesStorage.getProperties()
345+
} returnsMany listOf(properties, mapOf("newKey" to "newValue"))
346+
347+
val successLambda = slot<(SendPropertiesResult) -> Unit>()
348+
every {
349+
mockRepository.sendProperties(properties, capture(successLambda), any())
350+
} just runs
351+
every { propertiesManager.sendPropertiesWithDelay(any()) } just runs
352+
353+
// when - the flag is set mid-flight (as a setter would), then the request completes
354+
propertiesManager.forceSendProperties()
355+
propertiesManager.mockPrivateField(fieldIsSendingScheduled, true)
356+
successLambda.captured.invoke(SendPropertiesResult(emptyList(), emptyList()))
357+
358+
// then - no double scheduling on top of the setter's job
359+
verify(exactly = 0) {
360+
propertiesManager.sendPropertiesWithDelay(any())
361+
}
362+
}
363+
364+
@Test
365+
fun `should schedule follow-up send for properties set during the request`() {
366+
// given - the storage still holds properties after the sent snapshot is cleared
367+
every {
368+
mockPropertiesStorage.getProperties()
369+
} returnsMany listOf(properties, mapOf("newKey" to "newValue"))
370+
371+
every {
372+
mockRepository.sendProperties(properties, captureLambda(), any())
373+
} answers {
374+
lambda<(SendPropertiesResult) -> Unit>().captured.invoke(SendPropertiesResult(emptyList(), emptyList()))
375+
}
376+
every { propertiesManager.sendPropertiesWithDelay(any()) } just runs
377+
378+
// when
379+
propertiesManager.forceSendProperties()
380+
381+
// then
382+
verify(exactly = 1) {
383+
propertiesManager.sendPropertiesWithDelay(minDelay)
384+
}
385+
}
386+
309387
@Test
310388
fun setProperty() {
311389
// given

0 commit comments

Comments
 (0)