Skip to content

Commit eeff8b9

Browse files
committed
switching to Channel and improving test code
1 parent 25881c1 commit eeff8b9

3 files changed

Lines changed: 32 additions & 87 deletions

File tree

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

Lines changed: 18 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -40,57 +40,42 @@ 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.CoroutineDispatcher
4443
import kotlinx.coroutines.CoroutineScope
45-
import kotlinx.coroutines.CoroutineStart
4644
import kotlinx.coroutines.Dispatchers
47-
import kotlinx.coroutines.Job
4845
import kotlinx.coroutines.SupervisorJob
4946
import kotlinx.coroutines.cancel
5047
import kotlinx.coroutines.channels.BufferOverflow
51-
import kotlinx.coroutines.flow.MutableSharedFlow
48+
import kotlinx.coroutines.channels.Channel
5249
import kotlinx.coroutines.launch
5350
import org.jetbrains.annotations.Contract
5451
import timber.log.Timber
5552
import java.lang.ref.WeakReference
5653
import java.util.concurrent.CopyOnWriteArrayList
5754

5855
object ChangeManager {
59-
@get:VisibleForTesting
60-
@set:VisibleForTesting
61-
internal var dispatcher: CoroutineDispatcher = Dispatchers.Main
62-
set(value) {
63-
if (field == value) return
64-
field = value
65-
66-
resetCollector()
67-
}
68-
6956
private var scope: CoroutineScope? = null
70-
private var collectorJob: Job? = null
71-
72-
private val changeFlow =
73-
MutableSharedFlow<Pair<OpChanges, Any?>>(
74-
extraBufferCapacity = 64,
57+
private val changeChannel =
58+
Channel<Pair<OpChanges, Any?>>(
59+
capacity = 64,
7560
onBufferOverflow = BufferOverflow.DROP_OLDEST,
7661
)
7762

63+
@Synchronized
7864
private fun ensureCollector() {
79-
if (collectorJob?.isActive == true) return
80-
81-
val currentScope = CoroutineScope(dispatcher + SupervisorJob())
82-
scope = currentScope
83-
collectorJob =
84-
currentScope.launch(start = CoroutineStart.UNDISPATCHED) {
85-
changeFlow.collect { (changes, handler) ->
86-
notifySubscribers(changes, handler)
65+
if (scope != null) return
66+
scope =
67+
CoroutineScope(Dispatchers.Main + SupervisorJob()).also { s ->
68+
s.launch {
69+
for (event in changeChannel) {
70+
notifySubscribers(event.first, event.second)
71+
}
8772
}
8873
}
8974
}
9075

91-
private fun resetCollector() {
92-
collectorJob?.cancel()
93-
collectorJob = null
76+
@VisibleForTesting(otherwise = VisibleForTesting.NONE)
77+
@Synchronized
78+
fun resetForTesting() {
9479
scope?.cancel()
9580
scope = null
9681
}
@@ -108,18 +93,10 @@ object ChangeManager {
10893
is OpChangesAfterUndo -> changes.changes
10994
is OpChangesOnly -> changes.changes
11095
is ImportResponse -> changes.changes
111-
else -> {
112-
Timber.e("unhandled change type: ${changes::class}")
113-
return
114-
}
96+
else -> TODO("unhandled change type of class '${changes::class}'")
11597
}
11698

117-
val success = changeFlow.tryEmit(Pair(opChanges, handler))
118-
if (!success) {
119-
scope?.launch {
120-
changeFlow.emit(Pair(opChanges, handler))
121-
}
122-
}
99+
changeChannel.trySend(Pair(opChanges, handler))
123100
}
124101

125102
// do not make this a 'fun interface' - lambdas may immediately be GCed
@@ -233,10 +210,7 @@ object ChangeManager {
233210
is OpChangesAfterUndo -> changes.changes
234211
is OpChangesOnly -> changes.changes
235212
is ImportResponse -> changes.changes
236-
else -> {
237-
Timber.e("unhandled change type: ${changes::class}")
238-
return
239-
}
213+
else -> TODO("unhandled change type of class '${changes::class}'")
240214
}
241215
notifySubscribers(opChanges, initiator)
242216
}

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

Lines changed: 7 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -18,17 +18,14 @@ package com.ichi2.anki.observability
1818
import anki.collection.OpChanges
1919
import com.ichi2.anki.RobolectricTest
2020
import com.ichi2.testutils.JvmTest
21-
import kotlinx.coroutines.Dispatchers
21+
import com.ichi2.testutils.subscriberChangeCounter
2222
import kotlinx.coroutines.ExperimentalCoroutinesApi
23-
import kotlinx.coroutines.test.StandardTestDispatcher
2423
import kotlinx.coroutines.test.advanceUntilIdle
2524
import kotlinx.coroutines.test.runCurrent
26-
import kotlinx.coroutines.yield
2725
import org.hamcrest.MatcherAssert.assertThat
2826
import org.hamcrest.Matchers.equalTo
2927
import org.hamcrest.Matchers.greaterThan
3028
import org.junit.After
31-
import org.junit.Before
3229
import org.junit.Test
3330
import org.junit.runner.RunWith
3431
import org.junit.runners.Parameterized
@@ -120,58 +117,26 @@ class ChangeManagerTest {
120117
@OptIn(ExperimentalCoroutinesApi::class)
121118
@RunWith(RobolectricTestRunner::class)
122119
class ChangeManagerPublishTest : RobolectricTest() {
123-
private val testDispatcher = StandardTestDispatcher()
124-
125-
@Before
126-
override fun setUp() {
127-
super.setUp()
128-
ChangeManager.dispatcher = testDispatcher
129-
setupTestDispatcher(testDispatcher)
130-
}
131-
132120
@After
133121
override fun tearDown() {
134122
super.tearDown()
135123
ChangeManager.clearSubscribers()
136-
ChangeManager.dispatcher = Dispatchers.Main
124+
ChangeManager.resetForTesting()
137125
}
138126

139127
@Test
140128
fun `publish notifies multiple subscribers asynchronously`() =
141-
runTest(testDispatcher) {
142-
var firstNotified = false
143-
var secondNotified = false
144-
145-
val subscriber1 =
146-
object : ChangeManager.Subscriber {
147-
override fun opExecuted(
148-
changes: OpChanges,
149-
handler: Any?,
150-
) {
151-
firstNotified = true
152-
}
153-
}
154-
155-
val subscriber2 =
156-
object : ChangeManager.Subscriber {
157-
override fun opExecuted(
158-
changes: OpChanges,
159-
handler: Any?,
160-
) {
161-
secondNotified = true
162-
}
163-
}
164-
165-
ChangeManager.subscribe(subscriber1)
166-
ChangeManager.subscribe(subscriber2)
129+
runTest {
130+
val counter1 = subscriberChangeCounter()
131+
val counter2 = subscriberChangeCounter()
167132

168133
runCurrent()
169134
ChangeManager.publish(ChangeManager.ALL)
170135

171136
advanceUntilIdle()
172137
ShadowLooper.idleMainLooper()
173138

174-
assertThat("First subscriber should be notified", firstNotified, equalTo(true))
175-
assertThat("Second subscriber should be notified", secondNotified, equalTo(true))
139+
assertThat("First subscriber should be notified", counter1.hasChanges, equalTo(true))
140+
assertThat("Second subscriber should be notified", counter2.hasChanges, equalTo(true))
176141
}
177142
}

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ suspend fun ensureOpWithHandler(
7474
}
7575

7676
// used to ensure a strong reference to the subscription is held
77-
private class ChangeCounter : ChangeManager.Subscriber {
77+
internal class ChangeCounter : ChangeManager.Subscriber {
7878
private var changes = 0
7979
val changeCount get() = changes
8080
val hasChanges get() = changes > 0
@@ -99,3 +99,9 @@ private class ExtractOpHandler : ChangeManager.Subscriber {
9999
this.handler = handler
100100
}
101101
}
102+
103+
internal fun subscriberChangeCounter(): ChangeCounter {
104+
val counter = ChangeCounter()
105+
ChangeManager.subscribe(counter)
106+
return counter
107+
}

0 commit comments

Comments
 (0)