Skip to content

Commit f677ea5

Browse files
committed
decoupling consumer/producer in ChangeManager
1 parent 5377e4d commit f677ea5

2 files changed

Lines changed: 105 additions & 1 deletion

File tree

AnkiDroid/src/main/java/com/ichi2/anki/observability/ChangeManager.kt

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,12 +40,60 @@ import anki.collection.opChanges
4040
import anki.import_export.ImportResponse
4141
import com.ichi2.anki.common.crashreporting.CrashReportService
4242
import com.ichi2.anki.utils.ext.ifNotZero
43+
import kotlinx.coroutines.CoroutineScope
44+
import kotlinx.coroutines.Dispatchers
45+
import kotlinx.coroutines.SupervisorJob
46+
import kotlinx.coroutines.channels.BufferOverflow
47+
import kotlinx.coroutines.flow.MutableSharedFlow
48+
import kotlinx.coroutines.launch
4349
import org.jetbrains.annotations.Contract
4450
import timber.log.Timber
4551
import java.lang.ref.WeakReference
4652
import java.util.concurrent.CopyOnWriteArrayList
4753

4854
object ChangeManager {
55+
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
56+
57+
private val changeFlow =
58+
MutableSharedFlow<Pair<OpChanges, Any?>>(
59+
extraBufferCapacity = 64,
60+
onBufferOverflow = BufferOverflow.DROP_OLDEST,
61+
)
62+
63+
init {
64+
scope.launch {
65+
changeFlow.collect { (changes, handler) ->
66+
notifySubscribers(changes, handler)
67+
}
68+
}
69+
}
70+
71+
fun <T : Any> publish(
72+
changes: T,
73+
handler: Any? = null,
74+
) {
75+
val opChanges =
76+
when (changes) {
77+
is OpChanges -> changes
78+
is OpChangesWithCount -> changes.changes
79+
is OpChangesWithId -> changes.changes
80+
is OpChangesAfterUndo -> changes.changes
81+
is OpChangesOnly -> changes.changes
82+
is ImportResponse -> changes.changes
83+
else -> {
84+
Timber.e("unhandled change type: ${changes::class}")
85+
return
86+
}
87+
}
88+
89+
val success = changeFlow.tryEmit(Pair(opChanges, handler))
90+
if (!success) {
91+
scope.launch {
92+
changeFlow.emit(Pair(opChanges, handler))
93+
}
94+
}
95+
}
96+
4997
// do not make this a 'fun interface' - lambdas may immediately be GCed
5098
// due to the use of WeakReference
5199
interface Subscriber {
@@ -156,7 +204,10 @@ object ChangeManager {
156204
is OpChangesAfterUndo -> changes.changes
157205
is OpChangesOnly -> changes.changes
158206
is ImportResponse -> changes.changes
159-
else -> TODO("unhandled change type of class '${changes::class}'")
207+
else -> {
208+
Timber.e("unhandled change type: ${changes::class}")
209+
return
210+
}
160211
}
161212
notifySubscribers(opChanges, initiator)
162213
}

AnkiDroid/src/test/java/com/ichi2/anki/observability/ChangeManagerTest.kt

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ package com.ichi2.anki.observability
1717

1818
import anki.collection.OpChanges
1919
import com.ichi2.testutils.JvmTest
20+
import kotlinx.coroutines.test.advanceUntilIdle
21+
import kotlinx.coroutines.yield
2022
import org.hamcrest.MatcherAssert.assertThat
2123
import org.hamcrest.Matchers.equalTo
2224
import org.hamcrest.Matchers.greaterThan
@@ -25,6 +27,7 @@ import org.junit.Test
2527
import org.junit.runner.RunWith
2628
import org.junit.runners.Parameterized
2729
import org.robolectric.RobolectricTestRunner
30+
import org.robolectric.shadows.ShadowLooper
2831
import kotlin.reflect.KProperty1
2932
import kotlin.reflect.full.memberProperties
3033
import kotlin.reflect.javaType
@@ -107,3 +110,53 @@ class ChangeManagerTest {
107110
}
108111
}
109112
}
113+
114+
@RunWith(RobolectricTestRunner::class)
115+
class ChangeManagerPublishTest : JvmTest() {
116+
@After
117+
override fun tearDown() {
118+
super.tearDown()
119+
ChangeManager.clearSubscribers()
120+
}
121+
122+
@Test
123+
fun `publish notifies multiple subscribers asynchronously`() =
124+
runTest {
125+
ShadowLooper.idleMainLooper()
126+
yield()
127+
128+
var firstNotified = false
129+
var secondNotified = false
130+
131+
val subscriber1 =
132+
object : ChangeManager.Subscriber {
133+
override fun opExecuted(
134+
changes: OpChanges,
135+
handler: Any?,
136+
) {
137+
firstNotified = true
138+
}
139+
}
140+
141+
val subscriber2 =
142+
object : ChangeManager.Subscriber {
143+
override fun opExecuted(
144+
changes: OpChanges,
145+
handler: Any?,
146+
) {
147+
secondNotified = true
148+
}
149+
}
150+
151+
ChangeManager.subscribe(subscriber1)
152+
ChangeManager.subscribe(subscriber2)
153+
154+
ChangeManager.publish(ChangeManager.ALL)
155+
156+
advanceUntilIdle()
157+
ShadowLooper.idleMainLooper()
158+
159+
assertThat("First subscriber should be notified", firstNotified, equalTo(true))
160+
assertThat("Second subscriber should be notified", secondNotified, equalTo(true))
161+
}
162+
}

0 commit comments

Comments
 (0)