Skip to content

Commit 3c5b4bf

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 3c5b4bf

3 files changed

Lines changed: 91 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: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,21 @@
1515
*/
1616
package com.ichi2.anki.observability
1717

18+
import androidx.test.ext.junit.runners.AndroidJUnit4
1819
import anki.collection.OpChanges
20+
import com.ichi2.testutils.EmptyApplication
1921
import com.ichi2.testutils.JvmTest
22+
import com.ichi2.testutils.subscriberChangeCounter
23+
import kotlinx.coroutines.test.advanceUntilIdle
24+
import kotlinx.coroutines.test.runCurrent
2025
import org.hamcrest.MatcherAssert.assertThat
2126
import org.hamcrest.Matchers.equalTo
2227
import org.hamcrest.Matchers.greaterThan
2328
import org.junit.Test
2429
import org.junit.runner.RunWith
2530
import org.junit.runners.Parameterized
2631
import org.robolectric.RobolectricTestRunner
32+
import org.robolectric.annotation.Config
2733
import kotlin.reflect.KProperty1
2834
import kotlin.reflect.full.memberProperties
2935
import kotlin.reflect.javaType
@@ -100,3 +106,23 @@ class ChangeManagerTest {
100106
}
101107
}
102108
}
109+
110+
@RunWith(AndroidJUnit4::class)
111+
@Config(application = EmptyApplication::class)
112+
class ChangeManagerPublishTest : JvmTest() {
113+
@Test
114+
fun `publish notifies multiple subscribers asynchronously`() =
115+
runTest {
116+
val counter1 = subscriberChangeCounter()
117+
val counter2 = subscriberChangeCounter()
118+
119+
runCurrent()
120+
121+
ChangeManager.publish(ChangeManager.ALL)
122+
123+
advanceUntilIdle()
124+
125+
assertThat("First subscriber should be notified", counter1.hasChanges, equalTo(true))
126+
assertThat("Second subscriber should be notified", counter2.hasChanges, equalTo(true))
127+
}
128+
}

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)