Skip to content

Commit 6c5e920

Browse files
authored
Merge pull request #2233 from OneSignal/fix-ANR-operationRepo-load-with-coroutine-enqueue
Fix ANR caused by operationRepo.enqueue while loading is in progress
2 parents 7c15a59 + 3ffa73b commit 6c5e920

File tree

4 files changed

+103
-10
lines changed

4 files changed

+103
-10
lines changed
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package com.onesignal.common.threading
2+
3+
import kotlinx.coroutines.CoroutineScope
4+
import kotlinx.coroutines.launch
5+
import kotlinx.coroutines.newSingleThreadContext
6+
7+
object OSPrimaryCoroutineScope {
8+
// CoroutineScope tied to the main thread
9+
private val mainScope = CoroutineScope(newSingleThreadContext(name = "OSPrimaryCoroutineScope"))
10+
11+
/**
12+
* Executes the given [block] on the OS primary coroutine scope.
13+
*/
14+
fun execute(block: suspend () -> Unit) {
15+
mainScope.launch {
16+
block()
17+
}
18+
}
19+
}

OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/session/impl/SessionListener.kt

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.onesignal.session.internal.session.impl
22

3+
import com.onesignal.common.threading.OSPrimaryCoroutineScope
34
import com.onesignal.common.threading.suspendifyOnThread
45
import com.onesignal.core.internal.config.ConfigModelStore
56
import com.onesignal.core.internal.operations.IOperationRepo
@@ -40,7 +41,10 @@ internal class SessionListener(
4041
}
4142

4243
override fun onSessionStarted() {
43-
_operationRepo.enqueue(TrackSessionStartOperation(_configModelStore.model.appId, _identityModelStore.model.onesignalId), true)
44+
// enqueue the operation in background
45+
OSPrimaryCoroutineScope.execute {
46+
_operationRepo.enqueue(TrackSessionStartOperation(_configModelStore.model.appId, _identityModelStore.model.onesignalId), true)
47+
}
4448
}
4549

4650
override fun onSessionActive() {
@@ -54,9 +58,12 @@ internal class SessionListener(
5458
Logging.error("SessionListener.onSessionEnded sending duration of $durationInSeconds seconds")
5559
}
5660

57-
_operationRepo.enqueue(
58-
TrackSessionEndOperation(_configModelStore.model.appId, _identityModelStore.model.onesignalId, durationInSeconds),
59-
)
61+
// enqueue the operation in background
62+
OSPrimaryCoroutineScope.execute {
63+
_operationRepo.enqueue(
64+
TrackSessionEndOperation(_configModelStore.model.appId, _identityModelStore.model.onesignalId, durationInSeconds),
65+
)
66+
}
6067

6168
suspendifyOnThread {
6269
_outcomeEventsController.sendSessionEndOutcomeEvent(durationInSeconds)

OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/service/UserRefreshService.kt

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.onesignal.user.internal.service
22

33
import com.onesignal.common.IDManager
4+
import com.onesignal.common.threading.OSPrimaryCoroutineScope
45
import com.onesignal.core.internal.application.IApplicationService
56
import com.onesignal.core.internal.config.ConfigModelStore
67
import com.onesignal.core.internal.operations.IOperationRepo
@@ -28,12 +29,14 @@ class UserRefreshService(
2829
return
2930
}
3031

31-
_operationRepo.enqueue(
32-
RefreshUserOperation(
33-
_configModelStore.model.appId,
34-
_identityModelStore.model.onesignalId,
35-
),
36-
)
32+
OSPrimaryCoroutineScope.execute {
33+
_operationRepo.enqueue(
34+
RefreshUserOperation(
35+
_configModelStore.model.appId,
36+
_identityModelStore.model.onesignalId,
37+
),
38+
)
39+
}
3740
}
3841

3942
override fun start() = _sessionService.subscribe(this)

OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/operations/OperationRepoTests.kt

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,15 @@ import com.onesignal.common.threading.WaiterWithValue
55
import com.onesignal.core.internal.operations.impl.OperationModelStore
66
import com.onesignal.core.internal.operations.impl.OperationRepo
77
import com.onesignal.core.internal.operations.impl.OperationRepo.OperationQueueItem
8+
import com.onesignal.core.internal.preferences.PreferenceOneSignalKeys
9+
import com.onesignal.core.internal.preferences.PreferenceStores
810
import com.onesignal.core.internal.time.impl.Time
911
import com.onesignal.debug.LogLevel
1012
import com.onesignal.debug.internal.logging.Logging
1113
import com.onesignal.mocks.MockHelper
14+
import com.onesignal.mocks.MockPreferencesService
1215
import com.onesignal.user.internal.operations.ExecutorMocks.Companion.getNewRecordState
16+
import com.onesignal.user.internal.operations.LoginUserOperation
1317
import io.kotest.core.spec.style.FunSpec
1418
import io.kotest.matchers.shouldBe
1519
import io.mockk.CapturingSlot
@@ -28,6 +32,7 @@ import kotlinx.coroutines.launch
2832
import kotlinx.coroutines.withTimeout
2933
import kotlinx.coroutines.withTimeoutOrNull
3034
import kotlinx.coroutines.yield
35+
import org.json.JSONArray
3136
import java.util.UUID
3237

3338
// Mocks used by every test in this file
@@ -76,6 +81,65 @@ class OperationRepoTests : FunSpec({
7681
Logging.logLevel = LogLevel.NONE
7782
}
7883

84+
test("ensure loading in the background thread does not block enqueue") {
85+
// Given
86+
val prefs = MockPreferencesService()
87+
val mocks = Mocks()
88+
val operationModelStore: OperationModelStore = spyk(OperationModelStore(prefs))
89+
val operationRepo =
90+
spyk(
91+
OperationRepo(
92+
listOf(mocks.executor),
93+
operationModelStore,
94+
mocks.configModelStore,
95+
Time(),
96+
getNewRecordState(mocks.configModelStore),
97+
),
98+
)
99+
100+
val cachedOperation = LoginUserOperation()
101+
val newOperation = LoginUserOperation()
102+
val jsonArray = JSONArray()
103+
104+
// cache the operation
105+
jsonArray.put(cachedOperation.toJSON())
106+
prefs.saveString(PreferenceStores.ONESIGNAL, PreferenceOneSignalKeys.MODEL_STORE_PREFIX + "operations", jsonArray.toString())
107+
108+
cachedOperation.id = UUID.randomUUID().toString()
109+
newOperation.id = UUID.randomUUID().toString()
110+
every { operationModelStore.create(any()) } answers {
111+
// simulate a prolonged loading from cache
112+
Thread.sleep(1000)
113+
cachedOperation
114+
}
115+
116+
// simulate a background thread to load operations
117+
val backgroundThread =
118+
Thread {
119+
operationRepo.loadSavedOperations()
120+
}
121+
122+
val mainThread =
123+
Thread {
124+
operationRepo.enqueue(newOperation)
125+
}
126+
127+
// When
128+
backgroundThread.start()
129+
mainThread.start()
130+
131+
// Then
132+
// insertion from the main thread is done without blocking
133+
mainThread.join(500)
134+
operationRepo.queue.size shouldBe 1
135+
mainThread.state shouldBe Thread.State.TERMINATED
136+
137+
// after loading is completed, the cached operation should be at the beginning of the queue
138+
backgroundThread.join()
139+
operationRepo.queue.size shouldBe 2
140+
operationRepo.queue.first().operation shouldBe cachedOperation
141+
}
142+
79143
test("containsInstanceOf") {
80144
// Given
81145
val operationRepo = Mocks().operationRepo

0 commit comments

Comments
 (0)