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

@ -0,0 +1,47 @@
package com.keylesspalace.tusky.util
import com.bumptech.glide.RequestBuilder
import com.bumptech.glide.load.DataSource
import com.bumptech.glide.load.engine.GlideException
import com.bumptech.glide.request.RequestListener
import com.bumptech.glide.request.target.Target
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlinx.coroutines.suspendCancellableCoroutine
import okio.IOException
/**
* Allows waiting for a Glide request to complete without blocking a background thread.
*/
suspend fun <R> RequestBuilder<R>.submitAsync(
width: Int = Int.MIN_VALUE,
height: Int = Int.MIN_VALUE
): R {
return suspendCancellableCoroutine { continuation ->
val target = addListener(
object : RequestListener<R> {
override fun onLoadFailed(
e: GlideException?,
model: Any?,
target: Target<R>,
isFirstResource: Boolean
): Boolean {
continuation.resumeWithException(e ?: IOException("Image loading failed"))
return false
}
override fun onResourceReady(
resource: R & Any,
model: Any,
target: Target<R>?,
dataSource: DataSource,
isFirstResource: Boolean
): Boolean {
continuation.resume(resource)
return false
}
}
).submit(width, height)
continuation.invokeOnCancellation { target.cancel(true) }
}
}

View file

@ -29,9 +29,9 @@ import java.io.FileNotFoundException
import java.io.IOException
import java.io.InputStream
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Date
import java.util.Locale
import kotlin.time.Duration.Companion.hours
/**
* Helper methods for obtaining and resizing media files
@ -179,12 +179,10 @@ fun deleteStaleCachedMedia(mediaDirectory: File?) {
return
}
val twentyfourHoursAgo = Calendar.getInstance()
twentyfourHoursAgo.add(Calendar.HOUR, -24)
val unixTime = twentyfourHoursAgo.timeInMillis
val unixTime = System.currentTimeMillis() - 24.hours.inWholeMilliseconds
val files = mediaDirectory.listFiles { file -> unixTime > file.lastModified() && file.name.contains(MEDIA_TEMP_PREFIX) }
if (files == null || files.isEmpty()) {
if (files.isNullOrEmpty()) {
// Nothing to do
return
}

View file

@ -0,0 +1,37 @@
@file:JvmName("RelativeTimeUpdater")
package com.keylesspalace.tusky.util
import androidx.fragment.app.Fragment
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.coroutineScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.preference.PreferenceManager
import com.keylesspalace.tusky.settings.PrefKeys
import kotlin.time.Duration.Companion.minutes
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
private val UPDATE_INTERVAL = 1.minutes
/**
* Helper method to update adapter periodically to refresh timestamp
* if setting absoluteTimeView is false.
* Start updates when the Fragment becomes visible and stop when it is hidden.
*/
fun Fragment.updateRelativeTimePeriodically(callback: Runnable) {
val preferences = PreferenceManager.getDefaultSharedPreferences(requireContext())
val lifecycle = viewLifecycleOwner.lifecycle
lifecycle.coroutineScope.launch {
// This child coroutine will launch each time the Fragment moves to the STARTED state
lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
val useAbsoluteTime = preferences.getBoolean(PrefKeys.ABSOLUTE_TIME_VIEW, false)
if (!useAbsoluteTime) {
while (true) {
callback.run()
delay(UPDATE_INTERVAL)
}
}
}
}
}

View file

@ -30,71 +30,74 @@ import com.bumptech.glide.Glide
import com.keylesspalace.tusky.MainActivity
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.db.AccountEntity
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
import com.keylesspalace.tusky.di.ApplicationScope
import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
fun updateShortcut(context: Context, account: AccountEntity) {
Single.fromCallable {
val innerSize = context.resources.getDimensionPixelSize(R.dimen.adaptive_bitmap_inner_size)
val outerSize = context.resources.getDimensionPixelSize(R.dimen.adaptive_bitmap_outer_size)
class ShareShortcutHelper @Inject constructor(
private val context: Context,
@ApplicationScope private val externalScope: CoroutineScope
) {
val bmp = if (TextUtils.isEmpty(account.profilePictureUrl)) {
Glide.with(context)
.asBitmap()
.load(R.drawable.avatar_default)
.submit(innerSize, innerSize)
.get()
} else {
Glide.with(context)
.asBitmap()
.load(account.profilePictureUrl)
.error(R.drawable.avatar_default)
.submit(innerSize, innerSize)
.get()
fun updateShortcut(account: AccountEntity) {
externalScope.launch {
val innerSize = context.resources.getDimensionPixelSize(R.dimen.adaptive_bitmap_inner_size)
val outerSize = context.resources.getDimensionPixelSize(R.dimen.adaptive_bitmap_outer_size)
val bmp = if (TextUtils.isEmpty(account.profilePictureUrl)) {
Glide.with(context)
.asBitmap()
.load(R.drawable.avatar_default)
.submitAsync(innerSize, innerSize)
} else {
Glide.with(context)
.asBitmap()
.load(account.profilePictureUrl)
.error(R.drawable.avatar_default)
.submitAsync(innerSize, innerSize)
}
// inset the loaded bitmap inside a 108dp transparent canvas so it looks good as adaptive icon
val outBmp = Bitmap.createBitmap(outerSize, outerSize, Bitmap.Config.ARGB_8888)
val canvas = Canvas(outBmp)
canvas.drawBitmap(
bmp,
(outerSize - innerSize).toFloat() / 2f,
(outerSize - innerSize).toFloat() / 2f,
null
)
val icon = IconCompat.createWithAdaptiveBitmap(outBmp)
val person = Person.Builder()
.setIcon(icon)
.setName(account.displayName)
.setKey(account.identifier)
.build()
// This intent will be sent when the user clicks on one of the launcher shortcuts. Intent from share sheet will be different
val intent = Intent(context, MainActivity::class.java).apply {
action = Intent.ACTION_SEND
type = "text/plain"
putExtra(ShortcutManagerCompat.EXTRA_SHORTCUT_ID, account.id.toString())
}
val shortcutInfo = ShortcutInfoCompat.Builder(context, account.id.toString())
.setIntent(intent)
.setCategories(setOf("com.keylesspalace.tusky.Share"))
.setShortLabel(account.displayName)
.setPerson(person)
.setLongLived(true)
.setIcon(icon)
.build()
ShortcutManagerCompat.addDynamicShortcuts(context, listOf(shortcutInfo))
}
// inset the loaded bitmap inside a 108dp transparent canvas so it looks good as adaptive icon
val outBmp = Bitmap.createBitmap(outerSize, outerSize, Bitmap.Config.ARGB_8888)
val canvas = Canvas(outBmp)
canvas.drawBitmap(
bmp,
(outerSize - innerSize).toFloat() / 2f,
(outerSize - innerSize).toFloat() / 2f,
null
)
val icon = IconCompat.createWithAdaptiveBitmap(outBmp)
val person = Person.Builder()
.setIcon(icon)
.setName(account.displayName)
.setKey(account.identifier)
.build()
// This intent will be sent when the user clicks on one of the launcher shortcuts. Intent from share sheet will be different
val intent = Intent(context, MainActivity::class.java).apply {
action = Intent.ACTION_SEND
type = "text/plain"
putExtra(ShortcutManagerCompat.EXTRA_SHORTCUT_ID, account.id.toString())
}
val shortcutInfo = ShortcutInfoCompat.Builder(context, account.id.toString())
.setIntent(intent)
.setCategories(setOf("com.keylesspalace.tusky.Share"))
.setShortLabel(account.displayName)
.setPerson(person)
.setLongLived(true)
.setIcon(icon)
.build()
ShortcutManagerCompat.addDynamicShortcuts(context, listOf(shortcutInfo))
}
.subscribeOn(Schedulers.io())
.onErrorReturnItem(false)
.subscribe()
}
fun removeShortcut(context: Context, account: AccountEntity) {
ShortcutManagerCompat.removeDynamicShortcuts(context, listOf(account.id.toString()))
fun removeShortcut(account: AccountEntity) {
ShortcutManagerCompat.removeDynamicShortcuts(context, listOf(account.id.toString()))
}
}

View file

@ -0,0 +1,29 @@
package com.keylesspalace.tusky.util
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import at.connyduck.calladapter.networkresult.NetworkResult
import at.connyduck.calladapter.networkresult.fold
import java.util.function.Consumer
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
/**
* Simple reimplementation of RxJava's Single using a Kotlin coroutine,
* intended to be consumed by legacy Java code only.
*/
class Single<T>(private val producer: suspend CoroutineScope.() -> NetworkResult<T>) {
fun subscribe(
owner: LifecycleOwner,
onSuccess: Consumer<T>,
onError: Consumer<Throwable>
): Job {
return owner.lifecycleScope.launch {
producer().fold(
onSuccess = { onSuccess.accept(it) },
onFailure = { onError.accept(it) }
)
}
}
}