Replace RxJava3 code with coroutines (#4290)

This pull request removes the remaining RxJava code and replaces it with
coroutine-equivalent implementations.

- Remove all duplicate methods in `MastodonApi`:
- Methods returning a RxJava `Single` have been replaced by suspending
methods returning a `NetworkResult` in order to be consistent with the
new code.
- _sync_/_async_ method variants are replaced with the _async_ version
only (suspending method), and `runBlocking{}` is used to make the async
variant synchronous.
- Create a custom coroutine-based implementation of `Single` for usage
in Java code where launching a coroutine is not possible. This class can
be deleted after remaining Java code has been converted to Kotlin.
- `NotificationsFragment.java` can subscribe to `EventHub` events by
calling the new lifecycle-aware `EventHub.subscribe()` method. This
allows using the `SharedFlow` as single source of truth for all events.
- Rx Autodispose is replaced by `lifecycleScope.launch()` which will
automatically cancel the coroutine when the Fragment view/Activity is
destroyed.
- Background work is launched in the existing injectable
`externalScope`, since using `GlobalScope` is discouraged.
`externalScope` has been changed to be a `@Singleton` and to use the
main dispatcher by default.
- Transform `ShareShortcutHelper` to an injectable utility class so it
can use the application `Context` and `externalScope` as provided
dependencies to launch a background coroutine.
- Implement a custom Glide extension method
`RequestBuilder.submitAsync()` to do the same thing as
`RequestBuilder.submit().get()` in a non-blocking way. This way there is
no need to switch to a background dispatcher and block a background
thread, and cancellation is supported out-of-the-box.
- An utility method `Fragment.updateRelativeTimePeriodically()` has been
added to remove duplicate logic in `TimelineFragment` and
`NotificationsFragment`, and the logic is now implemented using a simple
coroutine instead of `Observable.interval()`. Note that the periodic
update now happens between onStart and onStop instead of between
onResume and onPause, since the Fragment is not interactive but is still
visible in the started state.
- Rewrite `BottomSheetActivityTest` using coroutines tests.
- Remove all RxJava library dependencies.
This commit is contained in:
Christophe Beyls 2024-02-29 15:28:48 +01:00 committed by GitHub
commit 40fde54e0b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 588 additions and 590 deletions

View file

@ -16,16 +16,19 @@
package com.keylesspalace.tusky
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import at.connyduck.calladapter.networkresult.NetworkResult
import com.keylesspalace.tusky.entity.SearchResult
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.entity.TimelineAccount
import com.keylesspalace.tusky.network.MastodonApi
import io.reactivex.rxjava3.android.plugins.RxAndroidPlugins
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.plugins.RxJavaPlugins
import io.reactivex.rxjava3.schedulers.TestScheduler
import java.util.Date
import java.util.concurrent.TimeUnit
import kotlin.time.Duration.Companion.milliseconds
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
@ -37,6 +40,7 @@ import org.mockito.Mockito.eq
import org.mockito.kotlin.doReturn
import org.mockito.kotlin.mock
@OptIn(ExperimentalCoroutinesApi::class)
class BottomSheetActivityTest {
@get:Rule
@ -48,8 +52,7 @@ class BottomSheetActivityTest {
private val statusQuery = "http://mastodon.foo.bar/@User/345678"
private val nonexistentStatusQuery = "http://mastodon.foo.bar/@User/345678000"
private val nonMastodonQuery = "http://medium.com/@correspondent/345678"
private val emptyCallback = Single.just(SearchResult(emptyList(), emptyList(), emptyList()))
private val testScheduler = TestScheduler()
private val emptyResult = NetworkResult.success(SearchResult(emptyList(), emptyList(), emptyList()))
private val account = TimelineAccount(
id = "1",
@ -60,7 +63,7 @@ class BottomSheetActivityTest {
url = "http://mastodon.foo.bar/@User",
avatar = ""
)
private val accountSingle = Single.just(SearchResult(listOf(account), emptyList(), emptyList()))
private val accountResult = NetworkResult.success(SearchResult(listOf(account), emptyList(), emptyList()))
private val status = Status(
id = "1",
@ -93,18 +96,15 @@ class BottomSheetActivityTest {
language = null,
filtered = null
)
private val statusSingle = Single.just(SearchResult(emptyList(), listOf(status), emptyList()))
private val statusResult = NetworkResult.success(SearchResult(emptyList(), listOf(status), emptyList()))
@Before
fun setup() {
RxJavaPlugins.setIoSchedulerHandler { testScheduler }
RxAndroidPlugins.setMainThreadSchedulerHandler { testScheduler }
apiMock = mock {
on { searchObservable(eq(accountQuery), eq(null), anyBoolean(), eq(null), eq(null), eq(null)) } doReturn accountSingle
on { searchObservable(eq(statusQuery), eq(null), anyBoolean(), eq(null), eq(null), eq(null)) } doReturn statusSingle
on { searchObservable(eq(nonexistentStatusQuery), eq(null), anyBoolean(), eq(null), eq(null), eq(null)) } doReturn accountSingle
on { searchObservable(eq(nonMastodonQuery), eq(null), anyBoolean(), eq(null), eq(null), eq(null)) } doReturn emptyCallback
onBlocking { search(eq(accountQuery), eq(null), anyBoolean(), eq(null), eq(null), eq(null)) } doReturn accountResult
onBlocking { search(eq(statusQuery), eq(null), anyBoolean(), eq(null), eq(null), eq(null)) } doReturn statusResult
onBlocking { search(eq(nonexistentStatusQuery), eq(null), anyBoolean(), eq(null), eq(null), eq(null)) } doReturn accountResult
onBlocking { search(eq(nonMastodonQuery), eq(null), anyBoolean(), eq(null), eq(null), eq(null)) } doReturn emptyResult
}
activity = FakeBottomSheetActivity(apiMock)
@ -157,86 +157,134 @@ class BottomSheetActivityTest {
}
@Test
fun search_inIdealConditions_returnsRequestedResults_forAccount() {
activity.viewUrl(accountQuery)
testScheduler.advanceTimeBy(100, TimeUnit.MILLISECONDS)
assertEquals(account.id, activity.accountId)
}
@Test
fun search_inIdealConditions_returnsRequestedResults_forStatus() {
activity.viewUrl(statusQuery)
testScheduler.advanceTimeBy(100, TimeUnit.MILLISECONDS)
assertEquals(status.id, activity.statusId)
}
@Test
fun search_inIdealConditions_returnsRequestedResults_forNonMastodonURL() {
activity.viewUrl(nonMastodonQuery)
testScheduler.advanceTimeBy(100, TimeUnit.MILLISECONDS)
assertEquals(nonMastodonQuery, activity.link)
}
@Test
fun search_withNoResults_appliesRequestedFallbackBehavior() {
for (fallbackBehavior in listOf(PostLookupFallbackBehavior.OPEN_IN_BROWSER, PostLookupFallbackBehavior.DISPLAY_ERROR)) {
activity.viewUrl(nonMastodonQuery, fallbackBehavior)
testScheduler.advanceTimeBy(100, TimeUnit.MILLISECONDS)
assertEquals(nonMastodonQuery, activity.link)
assertEquals(fallbackBehavior, activity.fallbackBehavior)
fun search_inIdealConditions_returnsRequestedResults_forAccount() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
try {
activity.viewUrl(accountQuery)
testScheduler.advanceTimeBy(100.milliseconds)
assertEquals(account.id, activity.accountId)
} finally {
Dispatchers.resetMain()
}
}
@Test
fun search_doesNotRespectUnrelatedResult() {
activity.viewUrl(nonexistentStatusQuery)
testScheduler.advanceTimeBy(100, TimeUnit.MILLISECONDS)
assertEquals(nonexistentStatusQuery, activity.link)
assertEquals(null, activity.accountId)
fun search_inIdealConditions_returnsRequestedResults_forStatus() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
try {
activity.viewUrl(statusQuery)
testScheduler.advanceTimeBy(100.milliseconds)
assertEquals(status.id, activity.statusId)
} finally {
Dispatchers.resetMain()
}
}
@Test
fun search_withCancellation_doesNotLoadUrl_forAccount() {
activity.viewUrl(accountQuery)
assertTrue(activity.isSearching())
activity.cancelActiveSearch()
assertFalse(activity.isSearching())
assertEquals(null, activity.accountId)
fun search_inIdealConditions_returnsRequestedResults_forNonMastodonURL() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
try {
activity.viewUrl(nonMastodonQuery)
testScheduler.advanceTimeBy(100.milliseconds)
assertEquals(nonMastodonQuery, activity.link)
} finally {
Dispatchers.resetMain()
}
}
@Test
fun search_withCancellation_doesNotLoadUrl_forStatus() {
activity.viewUrl(accountQuery)
activity.cancelActiveSearch()
assertEquals(null, activity.accountId)
fun search_withNoResults_appliesRequestedFallbackBehavior() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
try {
for (fallbackBehavior in listOf(
PostLookupFallbackBehavior.OPEN_IN_BROWSER,
PostLookupFallbackBehavior.DISPLAY_ERROR
)) {
activity.viewUrl(nonMastodonQuery, fallbackBehavior)
testScheduler.advanceTimeBy(100.milliseconds)
assertEquals(nonMastodonQuery, activity.link)
assertEquals(fallbackBehavior, activity.fallbackBehavior)
}
} finally {
Dispatchers.resetMain()
}
}
@Test
fun search_withCancellation_doesNotLoadUrl_forNonMastodonURL() {
activity.viewUrl(nonMastodonQuery)
activity.cancelActiveSearch()
assertEquals(null, activity.searchUrl)
fun search_doesNotRespectUnrelatedResult() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
try {
activity.viewUrl(nonexistentStatusQuery)
testScheduler.advanceTimeBy(100.milliseconds)
assertEquals(nonexistentStatusQuery, activity.link)
assertEquals(null, activity.accountId)
} finally {
Dispatchers.resetMain()
}
}
@Test
fun search_withPreviousCancellation_completes() {
// begin/cancel account search
activity.viewUrl(accountQuery)
activity.cancelActiveSearch()
fun search_withCancellation_doesNotLoadUrl_forAccount() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
try {
activity.viewUrl(accountQuery)
assertTrue(activity.isSearching())
activity.cancelActiveSearch()
assertFalse(activity.isSearching())
assertEquals(null, activity.accountId)
} finally {
Dispatchers.resetMain()
}
}
// begin status search
activity.viewUrl(statusQuery)
@Test
fun search_withCancellation_doesNotLoadUrl_forStatus() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
try {
activity.viewUrl(accountQuery)
activity.cancelActiveSearch()
assertEquals(null, activity.accountId)
} finally {
Dispatchers.resetMain()
}
}
// ensure that search is still ongoing
assertTrue(activity.isSearching())
@Test
fun search_withCancellation_doesNotLoadUrl_forNonMastodonURL() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
try {
activity.viewUrl(nonMastodonQuery)
activity.cancelActiveSearch()
assertEquals(null, activity.searchUrl)
} finally {
Dispatchers.resetMain()
}
}
// return searchResults
testScheduler.advanceTimeBy(100, TimeUnit.MILLISECONDS)
@Test
fun search_withPreviousCancellation_completes() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
try {
// begin/cancel account search
activity.viewUrl(accountQuery)
activity.cancelActiveSearch()
// ensure that the result of the status search was recorded
// and the account search wasn't
assertEquals(status.id, activity.statusId)
assertEquals(null, activity.accountId)
// begin status search
activity.viewUrl(statusQuery)
// ensure that search is still ongoing
assertTrue(activity.isSearching())
// return searchResults
testScheduler.advanceTimeBy(100.milliseconds)
// ensure that the result of the status search was recorded
// and the account search wasn't
assertEquals(status.id, activity.statusId)
assertEquals(null, activity.accountId)
} finally {
Dispatchers.resetMain()
}
}
class FakeBottomSheetActivity(api: MastodonApi) : BottomSheetActivity() {

View file

@ -17,6 +17,7 @@ import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.entity.Notification
import com.keylesspalace.tusky.entity.TimelineAccount
import java.util.Date
import kotlinx.coroutines.test.TestScope
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Before
@ -126,6 +127,8 @@ class MainActivityTest {
on { activeAccount } doReturn accountEntity
}
activity.draftsAlert = mock {}
activity.shareShortcutHelper = mock {}
activity.externalScope = TestScope()
activity.mastodonApi = mock {
onBlocking { accountVerifyCredentials() } doReturn NetworkResult.success(account)
onBlocking { listAnnouncements(false) } doReturn NetworkResult.success(emptyList())