Skip to content

Commit 1df2ada

Browse files
feat: decouple consumer/producer in ChangeManager
Use a Channel to decouple the two, and a synchronous 'publish' method. Fixes 21102 Fixes part of 20907 Co-authored-by: David Allison <62114487+david-allison@users.noreply.github.com>
1 parent 634e330 commit 1df2ada

3 files changed

Lines changed: 86 additions & 1 deletion

File tree

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

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,12 +40,56 @@ 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.cancel
47+
import kotlinx.coroutines.channels.Channel
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 var scope: CoroutineScope? = null
56+
private val changeChannel =
57+
Channel<Pair<OpChanges, Any?>>(
58+
capacity = Channel.UNLIMITED,
59+
)
60+
61+
@Synchronized
62+
private fun ensureCollector() {
63+
if (scope != null) return
64+
scope =
65+
CoroutineScope(Dispatchers.Main + SupervisorJob()).also { s ->
66+
s.launch {
67+
for (event in changeChannel) {
68+
notifySubscribers(event.first, event.second)
69+
}
70+
}
71+
}
72+
}
73+
74+
fun <T : Any> publish(
75+
changes: T,
76+
handler: Any? = null,
77+
) {
78+
ensureCollector()
79+
val opChanges =
80+
when (changes) {
81+
is OpChanges -> changes
82+
is OpChangesWithCount -> changes.changes
83+
is OpChangesWithId -> changes.changes
84+
is OpChangesAfterUndo -> changes.changes
85+
is OpChangesOnly -> changes.changes
86+
is ImportResponse -> changes.changes
87+
else -> TODO("unhandled change type of class '${changes::class}'")
88+
}
89+
90+
changeChannel.trySend(Pair(opChanges, handler))
91+
}
92+
4993
// do not make this a 'fun interface' - lambdas may immediately be GCed
5094
// due to the use of WeakReference
5195
interface Subscriber {
@@ -78,6 +122,7 @@ object ChangeManager {
78122
subscriber: Subscriber,
79123
owner: LifecycleOwner? = subscriber as? LifecycleOwner,
80124
) {
125+
ensureCollector()
81126
val weakRef = WeakReference(subscriber)
82127

83128
if (owner == null) {
@@ -139,7 +184,11 @@ object ChangeManager {
139184
}
140185

141186
@VisibleForTesting(otherwise = VisibleForTesting.NONE)
187+
@Synchronized
142188
fun resetForTesting() {
189+
scope?.cancel()
190+
scope = null
191+
changeChannel.drain()
143192
subscribers.size.ifNotZero { size -> Timber.d("clearing %d subscribers", size) }
144193
subscribers.clear()
145194
}
@@ -189,3 +238,7 @@ object ChangeManager {
189238
private fun <T> MutableList<WeakReference<T>>.removeWeakReferences(block: (T?) -> Boolean) {
190239
this.removeAll { block(it.get()) }
191240
}
241+
242+
private fun <T> Channel<T>.drain() {
243+
while (this.tryReceive().isSuccess) { /* intentionally empty */ }
244+
}

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

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

1818
import anki.collection.OpChanges
1919
import com.ichi2.testutils.JvmTest
20+
import com.ichi2.testutils.subscriberChangeCounter
21+
import kotlinx.coroutines.test.advanceUntilIdle
22+
import kotlinx.coroutines.test.runCurrent
2023
import org.hamcrest.MatcherAssert.assertThat
2124
import org.hamcrest.Matchers.equalTo
2225
import org.hamcrest.Matchers.greaterThan
@@ -100,3 +103,21 @@ class ChangeManagerTest {
100103
}
101104
}
102105
}
106+
107+
class ChangeManagerPublishTest : JvmTest() {
108+
@Test
109+
fun `publish notifies multiple subscribers asynchronously`() =
110+
runTest {
111+
val counter1 = subscriberChangeCounter()
112+
val counter2 = subscriberChangeCounter()
113+
114+
runCurrent()
115+
116+
ChangeManager.publish(ChangeManager.ALL)
117+
118+
advanceUntilIdle()
119+
120+
assertThat("First subscriber should be notified", counter1.hasChanges, equalTo(true))
121+
assertThat("Second subscriber should be notified", counter2.hasChanges, equalTo(true))
122+
}
123+
}

AnkiDroid/src/test/java/com/ichi2/testutils/TestChangeSubscriber.kt

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ suspend fun ensureOpWithHandler(
6060
}
6161

6262
// used to ensure a strong reference to the subscription is held
63-
private class ChangeCounter : ChangeManager.Subscriber {
63+
internal class ChangeCounter : ChangeManager.Subscriber {
6464
private var changes = 0
6565
val changeCount get() = changes
6666
val hasChanges get() = changes > 0
@@ -85,3 +85,14 @@ private class ExtractOpHandler : ChangeManager.Subscriber {
8585
this.handler = handler
8686
}
8787
}
88+
89+
/**
90+
* Produces a [ChangeCounter] which is subscribed to [ChangeManager].
91+
*
92+
* Query the result via [.changeCount][ChangeCounter.changeCount] or [.hasChanges][ChangeCounter.hasChanges]
93+
*/
94+
internal fun subscriberChangeCounter(): ChangeCounter {
95+
val counter = ChangeCounter()
96+
ChangeManager.subscribe(counter)
97+
return counter
98+
}

0 commit comments

Comments
 (0)