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
parent 91fe7a51cc
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

@ -127,7 +127,6 @@ configurations {
// library versions are in PROJECT_ROOT/gradle/libs.versions.toml // library versions are in PROJECT_ROOT/gradle/libs.versions.toml
dependencies { dependencies {
implementation libs.kotlinx.coroutines.android implementation libs.kotlinx.coroutines.android
implementation libs.kotlinx.coroutines.rx3
implementation libs.bundles.androidx implementation libs.bundles.androidx
implementation libs.bundles.room implementation libs.bundles.room
@ -147,10 +146,6 @@ dependencies {
implementation libs.bundles.glide implementation libs.bundles.glide
ksp libs.glide.compiler ksp libs.glide.compiler
implementation libs.bundles.rxjava3
implementation libs.bundles.autodispose
implementation libs.bundles.dagger implementation libs.bundles.dagger
kapt libs.bundles.dagger.processors kapt libs.bundles.dagger.processors

View File

@ -68,7 +68,6 @@
-keep,allowobfuscation,allowshrinking class * extends com.google.gson.reflect.TypeToken -keep,allowobfuscation,allowshrinking class * extends com.google.gson.reflect.TypeToken
# Retain generic signatures of classes used in MastodonApi so Retrofit works # Retain generic signatures of classes used in MastodonApi so Retrofit works
-keep,allowobfuscation,allowshrinking class io.reactivex.rxjava3.core.Single
-keep,allowobfuscation,allowshrinking class retrofit2.Response -keep,allowobfuscation,allowshrinking class retrofit2.Response
-keep,allowobfuscation,allowshrinking class kotlin.collections.List -keep,allowobfuscation,allowshrinking class kotlin.collections.List
-keep,allowobfuscation,allowshrinking class kotlin.collections.Map -keep,allowobfuscation,allowshrinking class kotlin.collections.Map

View File

@ -22,9 +22,8 @@ import android.view.View
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.Toast import android.widget.Toast
import androidx.annotation.VisibleForTesting import androidx.annotation.VisibleForTesting
import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope
import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider import at.connyduck.calladapter.networkresult.fold
import autodispose2.autoDispose
import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.keylesspalace.tusky.components.account.AccountActivity import com.keylesspalace.tusky.components.account.AccountActivity
import com.keylesspalace.tusky.components.viewthread.ViewThreadActivity import com.keylesspalace.tusky.components.viewthread.ViewThreadActivity
@ -32,8 +31,8 @@ import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.looksLikeMastodonUrl import com.keylesspalace.tusky.util.looksLikeMastodonUrl
import com.keylesspalace.tusky.util.openLink import com.keylesspalace.tusky.util.openLink
import com.keylesspalace.tusky.util.startActivityWithSlideInAnimation import com.keylesspalace.tusky.util.startActivityWithSlideInAnimation
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.launch
/** this is the base class for all activities that open links /** this is the base class for all activities that open links
* links are checked against the api if they are mastodon links so they can be opened in Tusky * links are checked against the api if they are mastodon links so they can be opened in Tusky
@ -74,39 +73,39 @@ abstract class BottomSheetActivity : BaseActivity() {
return return
} }
mastodonApi.searchObservable( lifecycleScope.launch {
query = url, mastodonApi.search(
resolve = true query = url,
).observeOn(AndroidSchedulers.mainThread()) resolve = true
.autoDispose(AndroidLifecycleScopeProvider.from(this, Lifecycle.Event.ON_DESTROY)) ).fold(
.subscribe( onSuccess = { (accounts, statuses) ->
{ (accounts, statuses) ->
if (getCancelSearchRequested(url)) { if (getCancelSearchRequested(url)) {
return@subscribe return@launch
} }
onEndSearch(url) onEndSearch(url)
if (statuses.isNotEmpty()) { if (statuses.isNotEmpty()) {
viewThread(statuses[0].id, statuses[0].url) viewThread(statuses[0].id, statuses[0].url)
return@subscribe return@launch
} }
accounts.firstOrNull { it.url.equals(url, ignoreCase = true) }?.let { account -> accounts.firstOrNull { it.url.equals(url, ignoreCase = true) }?.let { account ->
// Some servers return (unrelated) accounts for url searches (#2804) // Some servers return (unrelated) accounts for url searches (#2804)
// Verify that the account's url matches the query // Verify that the account's url matches the query
viewAccount(account.id) viewAccount(account.id)
return@subscribe return@launch
} }
performUrlFallbackAction(url, lookupFallbackBehavior) performUrlFallbackAction(url, lookupFallbackBehavior)
}, },
{ onFailure = {
if (!getCancelSearchRequested(url)) { if (!getCancelSearchRequested(url)) {
onEndSearch(url) onEndSearch(url)
performUrlFallbackAction(url, lookupFallbackBehavior) performUrlFallbackAction(url, lookupFallbackBehavior)
} }
} }
) )
}
onBeginSearch(url) onBeginSearch(url)
} }

View File

@ -91,6 +91,7 @@ import com.keylesspalace.tusky.components.trending.TrendingActivity
import com.keylesspalace.tusky.databinding.ActivityMainBinding import com.keylesspalace.tusky.databinding.ActivityMainBinding
import com.keylesspalace.tusky.db.AccountEntity import com.keylesspalace.tusky.db.AccountEntity
import com.keylesspalace.tusky.db.DraftsAlert import com.keylesspalace.tusky.db.DraftsAlert
import com.keylesspalace.tusky.di.ApplicationScope
import com.keylesspalace.tusky.entity.Account import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.entity.Notification import com.keylesspalace.tusky.entity.Notification
import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.Status
@ -102,6 +103,7 @@ import com.keylesspalace.tusky.pager.MainPagerAdapter
import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.usecase.DeveloperToolsUseCase import com.keylesspalace.tusky.usecase.DeveloperToolsUseCase
import com.keylesspalace.tusky.usecase.LogoutUsecase import com.keylesspalace.tusky.usecase.LogoutUsecase
import com.keylesspalace.tusky.util.ShareShortcutHelper
import com.keylesspalace.tusky.util.deleteStaleCachedMedia import com.keylesspalace.tusky.util.deleteStaleCachedMedia
import com.keylesspalace.tusky.util.emojify import com.keylesspalace.tusky.util.emojify
import com.keylesspalace.tusky.util.getDimension import com.keylesspalace.tusky.util.getDimension
@ -111,7 +113,6 @@ import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.startActivityWithSlideInAnimation import com.keylesspalace.tusky.util.startActivityWithSlideInAnimation
import com.keylesspalace.tusky.util.supportsOverridingActivityTransitions import com.keylesspalace.tusky.util.supportsOverridingActivityTransitions
import com.keylesspalace.tusky.util.unsafeLazy import com.keylesspalace.tusky.util.unsafeLazy
import com.keylesspalace.tusky.util.updateShortcut
import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.util.viewBinding
import com.mikepenz.iconics.IconicsDrawable import com.mikepenz.iconics.IconicsDrawable
import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
@ -143,8 +144,8 @@ import com.mikepenz.materialdrawer.widget.AccountHeaderView
import dagger.android.DispatchingAndroidInjector import dagger.android.DispatchingAndroidInjector
import dagger.android.HasAndroidInjector import dagger.android.HasAndroidInjector
import de.c1710.filemojicompat_ui.helpers.EMOJI_PREFERENCE import de.c1710.filemojicompat_ui.helpers.EMOJI_PREFERENCE
import io.reactivex.rxjava3.schedulers.Schedulers
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -167,6 +168,13 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
@Inject @Inject
lateinit var developerToolsUseCase: DeveloperToolsUseCase lateinit var developerToolsUseCase: DeveloperToolsUseCase
@Inject
lateinit var shareShortcutHelper: ShareShortcutHelper
@Inject
@ApplicationScope
lateinit var externalScope: CoroutineScope
private val binding by viewBinding(ActivityMainBinding::inflate) private val binding by viewBinding(ActivityMainBinding::inflate)
private lateinit var header: AccountHeaderView private lateinit var header: AccountHeaderView
@ -382,7 +390,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
} }
} }
Schedulers.io().scheduleDirect { externalScope.launch(Dispatchers.IO) {
// Flush old media that was cached for sharing // Flush old media that was cached for sharing
deleteStaleCachedMedia(applicationContext.getExternalFilesDir("Tusky")) deleteStaleCachedMedia(applicationContext.getExternalFilesDir("Tusky"))
} }
@ -1056,7 +1064,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
} }
updateProfiles() updateProfiles()
updateShortcut(this, accountManager.activeAccount!!) shareShortcutHelper.updateShortcut(accountManager.activeAccount!!)
} }
@SuppressLint("CheckResult") @SuppressLint("CheckResult")

View File

@ -22,7 +22,6 @@ import androidx.work.Constraints
import androidx.work.ExistingPeriodicWorkPolicy import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.PeriodicWorkRequestBuilder import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager import androidx.work.WorkManager
import autodispose2.AutoDisposePlugins
import com.keylesspalace.tusky.components.notifications.NotificationHelper import com.keylesspalace.tusky.components.notifications.NotificationHelper
import com.keylesspalace.tusky.di.AppInjector import com.keylesspalace.tusky.di.AppInjector
import com.keylesspalace.tusky.settings.AppTheme import com.keylesspalace.tusky.settings.AppTheme
@ -39,7 +38,6 @@ import dagger.android.HasAndroidInjector
import de.c1710.filemojicompat_defaults.DefaultEmojiPackList import de.c1710.filemojicompat_defaults.DefaultEmojiPackList
import de.c1710.filemojicompat_ui.helpers.EmojiPackHelper import de.c1710.filemojicompat_ui.helpers.EmojiPackHelper
import de.c1710.filemojicompat_ui.helpers.EmojiPreference import de.c1710.filemojicompat_ui.helpers.EmojiPreference
import io.reactivex.rxjava3.plugins.RxJavaPlugins
import java.security.Security import java.security.Security
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import javax.inject.Inject import javax.inject.Inject
@ -73,8 +71,6 @@ class TuskyApplication : Application(), HasAndroidInjector {
Security.insertProviderAt(Conscrypt.newProvider(), 1) Security.insertProviderAt(Conscrypt.newProvider(), 1)
AutoDisposePlugins.setHideProxies(false) // a small performance optimization
AppInjector.init(this) AppInjector.init(this)
// Migrate shared preference keys and defaults from version to version. // Migrate shared preference keys and defaults from version to version.
@ -97,10 +93,6 @@ class TuskyApplication : Application(), HasAndroidInjector {
localeManager.setLocale() localeManager.setLocale()
RxJavaPlugins.setErrorHandler {
Log.w("RxJava", "undeliverable exception", it)
}
NotificationHelper.createWorkerNotificationChannel(this) NotificationHelper.createWorkerNotificationChannel(this)
WorkManager.initialize( WorkManager.initialize(

View File

@ -42,13 +42,10 @@ import androidx.core.app.ShareCompat
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import androidx.core.content.IntentCompat import androidx.core.content.IntentCompat
import androidx.fragment.app.FragmentActivity import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope
import androidx.viewpager2.adapter.FragmentStateAdapter import androidx.viewpager2.adapter.FragmentStateAdapter
import androidx.viewpager2.widget.ViewPager2 import androidx.viewpager2.widget.ViewPager2
import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider
import autodispose2.autoDispose
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.bumptech.glide.request.FutureTarget
import com.keylesspalace.tusky.BuildConfig.APPLICATION_ID import com.keylesspalace.tusky.BuildConfig.APPLICATION_ID
import com.keylesspalace.tusky.components.viewthread.ViewThreadActivity import com.keylesspalace.tusky.components.viewthread.ViewThreadActivity
import com.keylesspalace.tusky.databinding.ActivityViewMediaBinding import com.keylesspalace.tusky.databinding.ActivityViewMediaBinding
@ -59,19 +56,18 @@ import com.keylesspalace.tusky.pager.ImagePagerAdapter
import com.keylesspalace.tusky.pager.SingleImagePagerAdapter import com.keylesspalace.tusky.pager.SingleImagePagerAdapter
import com.keylesspalace.tusky.util.getTemporaryMediaFilename import com.keylesspalace.tusky.util.getTemporaryMediaFilename
import com.keylesspalace.tusky.util.startActivityWithSlideInAnimation import com.keylesspalace.tusky.util.startActivityWithSlideInAnimation
import com.keylesspalace.tusky.util.submitAsync
import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.viewdata.AttachmentViewData import com.keylesspalace.tusky.viewdata.AttachmentViewData
import dagger.android.DispatchingAndroidInjector import dagger.android.DispatchingAndroidInjector
import dagger.android.HasAndroidInjector import dagger.android.HasAndroidInjector
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
import java.io.File import java.io.File
import java.io.FileNotFoundException
import java.io.FileOutputStream import java.io.FileOutputStream
import java.io.IOException import java.io.IOException
import java.util.Locale import java.util.Locale
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.launch
typealias ToolbarVisibilityListener = (isVisible: Boolean) -> Unit typealias ToolbarVisibilityListener = (isVisible: Boolean) -> Unit
@ -310,46 +306,37 @@ class ViewMediaActivity :
isCreating = true isCreating = true
binding.progressBarShare.visibility = View.VISIBLE binding.progressBarShare.visibility = View.VISIBLE
invalidateOptionsMenu() invalidateOptionsMenu()
val file = File(directory, getTemporaryMediaFilename("png"))
val futureTask: FutureTarget<Bitmap> = lifecycleScope.launch {
Glide.with(applicationContext).asBitmap().load(Uri.parse(url)).submit() val file = File(directory, getTemporaryMediaFilename("png"))
Single.fromCallable { val result = try {
val bitmap = futureTask.get() val bitmap =
try { Glide.with(applicationContext).asBitmap().load(Uri.parse(url)).submitAsync()
val stream = FileOutputStream(file) try {
bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream) FileOutputStream(file).use { stream ->
stream.close() bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream)
return@fromCallable true
} catch (fnfe: FileNotFoundException) {
Log.e(TAG, "Error writing temporary media.")
} catch (ioe: IOException) {
Log.e(TAG, "Error writing temporary media.")
}
return@fromCallable false
}
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.doOnDispose {
futureTask.cancel(true)
}
.autoDispose(AndroidLifecycleScopeProvider.from(this, Lifecycle.Event.ON_DESTROY))
.subscribe(
{ result ->
Log.d(TAG, "Download image result: $result")
isCreating = false
invalidateOptionsMenu()
binding.progressBarShare.visibility = View.GONE
if (result) {
shareFile(file, "image/png")
} }
}, true
{ error -> } catch (ioe: IOException) {
isCreating = false // FileNotFoundException is covered by IOException
invalidateOptionsMenu() Log.e(TAG, "Error writing temporary media.")
binding.progressBarShare.visibility = View.GONE false
Log.e(TAG, "Failed to download image", error) }.also { result -> Log.d(TAG, "Download image result: $result") }
} catch (error: Throwable) {
if (error is CancellationException) {
throw error
} }
) Log.e(TAG, "Failed to download image", error)
false
}
isCreating = false
invalidateOptionsMenu()
binding.progressBarShare.visibility = View.GONE
if (result) {
shareFile(file, "image/png")
}
}
} }
private fun shareMediaFile(directory: File, url: String) { private fun shareMediaFile(directory: File, url: String) {

View File

@ -1,30 +1,33 @@
package com.keylesspalace.tusky.appstore package com.keylesspalace.tusky.appstore
import io.reactivex.rxjava3.core.Observable import androidx.lifecycle.LifecycleOwner
import io.reactivex.rxjava3.subjects.PublishSubject import androidx.lifecycle.lifecycleScope
import java.util.function.Consumer
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.launch
interface Event interface Event
@Singleton @Singleton
class EventHub @Inject constructor() { class EventHub @Inject constructor() {
private val sharedEventFlow: MutableSharedFlow<Event> = MutableSharedFlow() private val sharedEventFlow = MutableSharedFlow<Event>()
val events: Flow<Event> = sharedEventFlow val events: SharedFlow<Event> = sharedEventFlow.asSharedFlow()
// TODO remove this old stuff as soon as NotificationsFragment is Kotlin
private val eventsSubject = PublishSubject.create<Event>()
val eventsObservable: Observable<Event> = eventsSubject
suspend fun dispatch(event: Event) { suspend fun dispatch(event: Event) {
sharedEventFlow.emit(event) sharedEventFlow.emit(event)
eventsSubject.onNext(event)
} }
fun dispatchOld(event: Event) { // TODO remove as soon as NotificationsFragment is Kotlin
eventsSubject.onNext(event) fun subscribe(lifecycleOwner: LifecycleOwner, consumer: Consumer<Event>) {
lifecycleOwner.lifecycleScope.launch {
events.collect { event ->
consumer.accept(event)
}
}
} }
} }

View File

@ -19,7 +19,6 @@ import android.os.Bundle
import android.util.Log import android.util.Log
import android.view.View import android.view.View
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.ConcatAdapter import androidx.recyclerview.widget.ConcatAdapter
@ -28,8 +27,6 @@ import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.SimpleItemAnimator import androidx.recyclerview.widget.SimpleItemAnimator
import at.connyduck.calladapter.networkresult.fold import at.connyduck.calladapter.networkresult.fold
import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider.from
import autodispose2.autoDispose
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import com.keylesspalace.tusky.BottomSheetActivity import com.keylesspalace.tusky.BottomSheetActivity
import com.keylesspalace.tusky.PostLookupFallbackBehavior import com.keylesspalace.tusky.PostLookupFallbackBehavior
@ -58,7 +55,6 @@ import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.startActivityWithSlideInAnimation import com.keylesspalace.tusky.util.startActivityWithSlideInAnimation
import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.view.EndlessOnScrollListener import com.keylesspalace.tusky.view.EndlessOnScrollListener
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import retrofit2.Response import retrofit2.Response
@ -249,17 +245,16 @@ class AccountListFragment :
} }
override fun onRespondToFollowRequest(accept: Boolean, accountId: String, position: Int) { override fun onRespondToFollowRequest(accept: Boolean, accountId: String, position: Int) {
if (accept) { viewLifecycleOwner.lifecycleScope.launch {
api.authorizeFollowRequest(accountId) if (accept) {
} else { api.authorizeFollowRequest(accountId)
api.rejectFollowRequest(accountId) } else {
}.observeOn(AndroidSchedulers.mainThread()) api.rejectFollowRequest(accountId)
.autoDispose(from(this, Lifecycle.Event.ON_DESTROY)) }.fold(
.subscribe( onSuccess = {
{
onRespondToFollowRequestSuccess(position) onRespondToFollowRequestSuccess(position)
}, },
{ throwable -> onFailure = { throwable ->
val verb = if (accept) { val verb = if (accept) {
"accept" "accept"
} else { } else {
@ -268,6 +263,7 @@ class AccountListFragment :
Log.e(TAG, "Failed to $verb account id $accountId.", throwable) Log.e(TAG, "Failed to $verb account id $accountId.", throwable)
} }
) )
}
} }
private fun onRespondToFollowRequestSuccess(position: Int) { private fun onRespondToFollowRequestSuccess(position: Int) {

View File

@ -50,6 +50,7 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
class ComposeViewModel @Inject constructor( class ComposeViewModel @Inject constructor(
@ -412,9 +413,9 @@ class ComposeViewModel @Inject constructor(
} }
fun searchAutocompleteSuggestions(token: String): List<AutocompleteResult> { fun searchAutocompleteSuggestions(token: String): List<AutocompleteResult> {
when (token[0]) { return when (token[0]) {
'@' -> { '@' -> runBlocking {
return api.searchAccountsSync(query = token.substring(1), limit = 10) api.searchAccounts(query = token.substring(1), limit = 10)
.fold({ accounts -> .fold({ accounts ->
accounts.map { AutocompleteResult.AccountResult(it) } accounts.map { AutocompleteResult.AccountResult(it) }
}, { e -> }, { e ->
@ -422,8 +423,8 @@ class ComposeViewModel @Inject constructor(
emptyList() emptyList()
}) })
} }
'#' -> { '#' -> runBlocking {
return api.searchSync( api.search(
query = token, query = token,
type = SearchType.Hashtag.apiParameter, type = SearchType.Hashtag.apiParameter,
limit = 10 limit = 10
@ -439,7 +440,7 @@ class ComposeViewModel @Inject constructor(
val emojiList = emoji.replayCache.firstOrNull() ?: return emptyList() val emojiList = emoji.replayCache.firstOrNull() ?: return emptyList()
val incomplete = token.substring(1) val incomplete = token.substring(1)
return emojiList.filter { emoji -> emojiList.filter { emoji ->
emoji.shortcode.contains(incomplete, ignoreCase = true) emoji.shortcode.contains(incomplete, ignoreCase = true)
}.sortedBy { emoji -> }.sortedBy { emoji ->
emoji.shortcode.indexOf(incomplete, ignoreCase = true) emoji.shortcode.indexOf(incomplete, ignoreCase = true)
@ -449,7 +450,7 @@ class ComposeViewModel @Inject constructor(
} }
else -> { else -> {
Log.w(TAG, "Unexpected autocompletion token: $token") Log.w(TAG, "Unexpected autocompletion token: $token")
return emptyList() emptyList()
} }
} }
} }

View File

@ -14,6 +14,7 @@ import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.entity.HashTag import com.keylesspalace.tusky.entity.HashTag
import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MastodonApi
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.runBlocking
class FollowedTagsViewModel @Inject constructor( class FollowedTagsViewModel @Inject constructor(
private val api: MastodonApi private val api: MastodonApi
@ -38,17 +39,19 @@ class FollowedTagsViewModel @Inject constructor(
fun searchAutocompleteSuggestions( fun searchAutocompleteSuggestions(
token: String token: String
): List<ComposeAutoCompleteAdapter.AutocompleteResult> { ): List<ComposeAutoCompleteAdapter.AutocompleteResult> {
return api.searchSync(query = token, type = SearchType.Hashtag.apiParameter, limit = 10) return runBlocking {
.fold({ searchResult -> api.search(query = token, type = SearchType.Hashtag.apiParameter, limit = 10)
searchResult.hashtags.map { .fold({ searchResult ->
ComposeAutoCompleteAdapter.AutocompleteResult.HashtagResult( searchResult.hashtags.map {
it.name ComposeAutoCompleteAdapter.AutocompleteResult.HashtagResult(
) it.name
} )
}, { e -> }
Log.e(TAG, "Autocomplete search for $token failed.", e) }, { e ->
emptyList() Log.e(TAG, "Autocomplete search for $token failed.", e)
}) emptyList()
})
}
} }
companion object { companion object {

View File

@ -18,12 +18,11 @@ package com.keylesspalace.tusky.components.report.adapter
import android.util.Log import android.util.Log
import androidx.paging.PagingSource import androidx.paging.PagingSource
import androidx.paging.PagingState import androidx.paging.PagingState
import at.connyduck.calladapter.networkresult.getOrThrow
import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MastodonApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.rx3.await import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.withContext
class StatusesPagingSource( class StatusesPagingSource(
private val accountId: String, private val accountId: String,
@ -40,7 +39,9 @@ class StatusesPagingSource(
val key = params.key val key = params.key
try { try {
val result = if (params is LoadParams.Refresh && key != null) { val result = if (params is LoadParams.Refresh && key != null) {
withContext(Dispatchers.IO) { // Use coroutineScope to ensure that one failed call will cancel the other one
// and the source Exception will be propagated locally.
coroutineScope {
val initialStatus = async { getSingleStatus(key) } val initialStatus = async { getSingleStatus(key) }
val additionalStatuses = val additionalStatuses =
async { getStatusList(maxId = key, limit = params.loadSize - 1) } async { getStatusList(maxId = key, limit = params.loadSize - 1) }
@ -73,7 +74,7 @@ class StatusesPagingSource(
} }
private suspend fun getSingleStatus(statusId: String): Status { private suspend fun getSingleStatus(statusId: String): Status {
return mastodonApi.statusObservable(statusId).await() return mastodonApi.status(statusId).getOrThrow()
} }
private suspend fun getStatusList( private suspend fun getStatusList(
@ -81,13 +82,13 @@ class StatusesPagingSource(
maxId: String? = null, maxId: String? = null,
limit: Int limit: Int
): List<Status> { ): List<Status> {
return mastodonApi.accountStatusesObservable( return mastodonApi.accountStatuses(
accountId = accountId, accountId = accountId,
maxId = maxId, maxId = maxId,
sinceId = null, sinceId = null,
minId = minId, minId = minId,
limit = limit, limit = limit,
excludeReblogs = true excludeReblogs = true
).await() ).getOrThrow()
} }
} }

View File

@ -18,9 +18,9 @@ package com.keylesspalace.tusky.components.scheduled
import android.util.Log import android.util.Log
import androidx.paging.PagingSource import androidx.paging.PagingSource
import androidx.paging.PagingState import androidx.paging.PagingState
import at.connyduck.calladapter.networkresult.getOrThrow
import com.keylesspalace.tusky.entity.ScheduledStatus import com.keylesspalace.tusky.entity.ScheduledStatus
import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MastodonApi
import kotlinx.coroutines.rx3.await
class ScheduledStatusPagingSourceFactory( class ScheduledStatusPagingSourceFactory(
private val mastodonApi: MastodonApi private val mastodonApi: MastodonApi
@ -63,7 +63,7 @@ class ScheduledStatusPagingSource(
val result = mastodonApi.scheduledStatuses( val result = mastodonApi.scheduledStatuses(
maxId = params.key, maxId = params.key,
limit = params.loadSize limit = params.loadSize
).await() ).getOrThrow()
LoadResult.Page( LoadResult.Page(
data = result, data = result,

View File

@ -17,10 +17,10 @@ package com.keylesspalace.tusky.components.search.adapter
import androidx.paging.PagingSource import androidx.paging.PagingSource
import androidx.paging.PagingState import androidx.paging.PagingState
import at.connyduck.calladapter.networkresult.getOrThrow
import com.keylesspalace.tusky.components.search.SearchType import com.keylesspalace.tusky.components.search.SearchType
import com.keylesspalace.tusky.entity.SearchResult import com.keylesspalace.tusky.entity.SearchResult
import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MastodonApi
import kotlinx.coroutines.rx3.await
class SearchPagingSource<T : Any>( class SearchPagingSource<T : Any>(
private val mastodonApi: MastodonApi, private val mastodonApi: MastodonApi,
@ -54,14 +54,14 @@ class SearchPagingSource<T : Any>(
val currentKey = params.key ?: 0 val currentKey = params.key ?: 0
try { try {
val data = mastodonApi.searchObservable( val data = mastodonApi.search(
query = searchRequest, query = searchRequest,
type = searchType.apiParameter, type = searchType.apiParameter,
resolve = true, resolve = true,
limit = params.loadSize, limit = params.loadSize,
offset = currentKey, offset = currentKey,
following = false following = false
).await() ).getOrThrow()
val res = parser(data) val res = parser(data)

View File

@ -37,7 +37,6 @@ import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.SimpleItemAnimator import androidx.recyclerview.widget.SimpleItemAnimator
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener import androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener
import at.connyduck.sparkbutton.helpers.Utils import at.connyduck.sparkbutton.helpers.Utils
import autodispose2.androidx.lifecycle.autoDispose
import com.google.android.material.color.MaterialColors import com.google.android.material.color.MaterialColors
import com.keylesspalace.tusky.R import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.adapter.StatusBaseViewHolder import com.keylesspalace.tusky.adapter.StatusBaseViewHolder
@ -67,6 +66,7 @@ import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.startActivityWithSlideInAnimation import com.keylesspalace.tusky.util.startActivityWithSlideInAnimation
import com.keylesspalace.tusky.util.unsafeLazy import com.keylesspalace.tusky.util.unsafeLazy
import com.keylesspalace.tusky.util.updateRelativeTimePeriodically
import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.viewdata.AttachmentViewData import com.keylesspalace.tusky.viewdata.AttachmentViewData
import com.keylesspalace.tusky.viewdata.StatusViewData import com.keylesspalace.tusky.viewdata.StatusViewData
@ -74,9 +74,6 @@ import com.mikepenz.iconics.IconicsDrawable
import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
import com.mikepenz.iconics.utils.colorInt import com.mikepenz.iconics.utils.colorInt
import com.mikepenz.iconics.utils.sizeDp import com.mikepenz.iconics.utils.sizeDp
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Observable
import java.util.concurrent.TimeUnit
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -316,6 +313,14 @@ class TimelineFragment :
} }
} }
} }
updateRelativeTimePeriodically {
adapter.notifyItemRangeChanged(
0,
adapter.itemCount,
listOf(StatusBaseViewHolder.Key.KEY_CREATED)
)
}
} }
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
@ -598,29 +603,6 @@ class TimelineFragment :
if (talkBackWasEnabled && !wasEnabled) { if (talkBackWasEnabled && !wasEnabled) {
adapter.notifyItemRangeChanged(0, adapter.itemCount) adapter.notifyItemRangeChanged(0, adapter.itemCount)
} }
startUpdateTimestamp()
}
/**
* Start to update adapter every minute to refresh timestamp
* If setting absoluteTimeView is false
* Auto dispose observable on pause
*/
private fun startUpdateTimestamp() {
val preferences = PreferenceManager.getDefaultSharedPreferences(requireContext())
val useAbsoluteTime = preferences.getBoolean(PrefKeys.ABSOLUTE_TIME_VIEW, false)
if (!useAbsoluteTime) {
Observable.interval(0, 1, TimeUnit.MINUTES)
.observeOn(AndroidSchedulers.mainThread())
.autoDispose(this, Lifecycle.Event.ON_PAUSE)
.subscribe {
adapter.notifyItemRangeChanged(
0,
adapter.itemCount,
listOf(StatusBaseViewHolder.Key.KEY_CREATED)
)
}
}
} }
override fun onReselect() { override fun onReselect() {

View File

@ -20,6 +20,7 @@ package com.keylesspalace.tusky.di
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import javax.inject.Qualifier import javax.inject.Qualifier
import javax.inject.Singleton
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
@ -40,5 +41,6 @@ annotation class ApplicationScope
class CoroutineScopeModule { class CoroutineScopeModule {
@ApplicationScope @ApplicationScope
@Provides @Provides
fun providesApplicationScope() = CoroutineScope(SupervisorJob() + Dispatchers.Default) @Singleton
fun providesApplicationScope() = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
} }

View File

@ -46,7 +46,6 @@ import okhttp3.OkHttp
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit import retrofit2.Retrofit
import retrofit2.adapter.rxjava3.RxJava3CallAdapterFactory
import retrofit2.converter.gson.GsonConverterFactory import retrofit2.converter.gson.GsonConverterFactory
import retrofit2.create import retrofit2.create
@ -118,7 +117,6 @@ class NetworkModule {
return Retrofit.Builder().baseUrl("https://" + MastodonApi.PLACEHOLDER_DOMAIN) return Retrofit.Builder().baseUrl("https://" + MastodonApi.PLACEHOLDER_DOMAIN)
.client(httpClient) .client(httpClient)
.addConverterFactory(GsonConverterFactory.create(gson)) .addConverterFactory(GsonConverterFactory.create(gson))
.addCallAdapterFactory(RxJava3CallAdapterFactory.create())
.addCallAdapterFactory(NetworkResultCallAdapterFactory.create()) .addCallAdapterFactory(NetworkResultCallAdapterFactory.create())
.build() .build()
} }

View File

@ -16,8 +16,6 @@
package com.keylesspalace.tusky.fragment; package com.keylesspalace.tusky.fragment;
import static com.keylesspalace.tusky.util.StringUtils.isLessThan; import static com.keylesspalace.tusky.util.StringUtils.isLessThan;
import static autodispose2.AutoDispose.autoDisposable;
import static autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider.from;
import android.app.Activity; import android.app.Activity;
import android.content.Context; import android.content.Context;
@ -86,6 +84,8 @@ import com.keylesspalace.tusky.util.ListStatusAccessibilityDelegate;
import com.keylesspalace.tusky.util.ListUtils; import com.keylesspalace.tusky.util.ListUtils;
import com.keylesspalace.tusky.util.NotificationTypeConverterKt; import com.keylesspalace.tusky.util.NotificationTypeConverterKt;
import com.keylesspalace.tusky.util.PairedList; import com.keylesspalace.tusky.util.PairedList;
import com.keylesspalace.tusky.util.RelativeTimeUpdater;
import com.keylesspalace.tusky.util.Single;
import com.keylesspalace.tusky.util.StatusDisplayOptions; import com.keylesspalace.tusky.util.StatusDisplayOptions;
import com.keylesspalace.tusky.util.ViewDataUtils; import com.keylesspalace.tusky.util.ViewDataUtils;
import com.keylesspalace.tusky.view.EndlessOnScrollListener; import com.keylesspalace.tusky.view.EndlessOnScrollListener;
@ -102,19 +102,14 @@ import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Objects; import java.util.Objects;
import java.util.Set; import java.util.Set;
import java.util.concurrent.TimeUnit;
import javax.inject.Inject; import javax.inject.Inject;
import at.connyduck.sparkbutton.helpers.Utils; import at.connyduck.sparkbutton.helpers.Utils;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.core.Observable;
import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.disposables.CompositeDisposable;
import io.reactivex.rxjava3.disposables.Disposable;
import kotlin.Unit; import kotlin.Unit;
import kotlin.collections.CollectionsKt; import kotlin.collections.CollectionsKt;
import kotlin.jvm.functions.Function1; import kotlin.jvm.functions.Function1;
import kotlinx.coroutines.Job;
public class NotificationsFragment extends SFragment implements public class NotificationsFragment extends SFragment implements
SwipeRefreshLayout.OnRefreshListener, SwipeRefreshLayout.OnRefreshListener,
@ -131,7 +126,7 @@ public class NotificationsFragment extends SFragment implements
private final Set<Notification.Type> notificationFilter = new HashSet<>(); private final Set<Notification.Type> notificationFilter = new HashSet<>();
private final CompositeDisposable disposables = new CompositeDisposable(); private final ArrayList<Job> jobs = new ArrayList<>();
private enum FetchEnd { private enum FetchEnd {
TOP, TOP,
@ -382,10 +377,9 @@ public class NotificationsFragment extends SFragment implements
binding.recyclerView.addOnScrollListener(scrollListener); binding.recyclerView.addOnScrollListener(scrollListener);
eventHub.getEventsObservable() eventHub.subscribe(
.observeOn(AndroidSchedulers.mainThread()) getViewLifecycleOwner(),
.to(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY))) event -> {
.subscribe(event -> {
if (event instanceof StatusChangedEvent) { if (event instanceof StatusChangedEvent) {
Status updatedStatus = ((StatusChangedEvent) event).getStatus(); Status updatedStatus = ((StatusChangedEvent) event).getStatus();
updateStatus(updatedStatus.getActionableId(), s -> updatedStatus); updateStatus(updatedStatus.getActionableId(), s -> updatedStatus);
@ -394,7 +388,10 @@ public class NotificationsFragment extends SFragment implements
} else if (event instanceof PreferenceChangedEvent) { } else if (event instanceof PreferenceChangedEvent) {
onPreferenceChanged(((PreferenceChangedEvent) event).getPreferenceKey()); onPreferenceChanged(((PreferenceChangedEvent) event).getPreferenceKey());
} }
}); }
);
RelativeTimeUpdater.updateRelativeTimePeriodically(this, this::updateAdapter);
} }
@Override @Override
@ -422,13 +419,12 @@ public class NotificationsFragment extends SFragment implements
final Status status = notification.getStatus(); final Status status = notification.getStatus();
Objects.requireNonNull(status, "Reblog on notification without status"); Objects.requireNonNull(status, "Reblog on notification without status");
timelineCases.reblogOld(status.getId(), reblog) timelineCases.reblogOld(status.getId(), reblog)
.observeOn(AndroidSchedulers.mainThread()) .subscribe(
.to(autoDisposable(from(this))) getViewLifecycleOwner(),
.subscribe( (newStatus) -> setReblogForStatus(status.getId(), reblog),
(newStatus) -> setReblogForStatus(status.getId(), reblog), (t) -> Log.d(getClass().getSimpleName(),
(t) -> Log.d(getClass().getSimpleName(), "Failed to reblog status: " + status.getId(), t)
"Failed to reblog status: " + status.getId(), t) );
);
} }
private void setReblogForStatus(String statusId, boolean reblog) { private void setReblogForStatus(String statusId, boolean reblog) {
@ -441,13 +437,12 @@ public class NotificationsFragment extends SFragment implements
final Status status = notification.getStatus(); final Status status = notification.getStatus();
timelineCases.favouriteOld(status.getId(), favourite) timelineCases.favouriteOld(status.getId(), favourite)
.observeOn(AndroidSchedulers.mainThread()) .subscribe(
.to(autoDisposable(from(this))) getViewLifecycleOwner(),
.subscribe( (newStatus) -> setFavouriteForStatus(status.getId(), favourite),
(newStatus) -> setFavouriteForStatus(status.getId(), favourite), (t) -> Log.d(getClass().getSimpleName(),
(t) -> Log.d(getClass().getSimpleName(), "Failed to favourite status: " + status.getId(), t)
"Failed to favourite status: " + status.getId(), t) );
);
} }
private void setFavouriteForStatus(String statusId, boolean favourite) { private void setFavouriteForStatus(String statusId, boolean favourite) {
@ -460,13 +455,12 @@ public class NotificationsFragment extends SFragment implements
final Status status = notification.getStatus(); final Status status = notification.getStatus();
timelineCases.bookmarkOld(status.getActionableId(), bookmark) timelineCases.bookmarkOld(status.getActionableId(), bookmark)
.observeOn(AndroidSchedulers.mainThread()) .subscribe(
.to(autoDisposable(from(this))) getViewLifecycleOwner(),
.subscribe( (newStatus) -> setBookmarkForStatus(status.getId(), bookmark),
(newStatus) -> setBookmarkForStatus(status.getId(), bookmark), (t) -> Log.d(getClass().getSimpleName(),
(t) -> Log.d(getClass().getSimpleName(), "Failed to bookmark status: " + status.getId(), t)
"Failed to bookmark status: " + status.getId(), t) );
);
} }
private void setBookmarkForStatus(String statusId, boolean bookmark) { private void setBookmarkForStatus(String statusId, boolean bookmark) {
@ -477,13 +471,11 @@ public class NotificationsFragment extends SFragment implements
final Notification notification = notifications.get(position).asRight(); final Notification notification = notifications.get(position).asRight();
final Status status = notification.getStatus().getActionableStatus(); final Status status = notification.getStatus().getActionableStatus();
timelineCases.voteInPollOld(status.getId(), status.getPoll().getId(), choices) timelineCases.voteInPollOld(status.getId(), status.getPoll().getId(), choices)
.observeOn(AndroidSchedulers.mainThread()) .subscribe(
.to(autoDisposable(from(this))) getViewLifecycleOwner(),
.subscribe( (newPoll) -> setVoteForPoll(status, newPoll),
(newPoll) -> setVoteForPoll(status, newPoll), (t) -> Log.d(TAG, "Failed to vote in poll: " + status.getId(), t)
(t) -> Log.d(TAG, );
"Failed to vote in poll: " + status.getId(), t)
);
} }
@Override @Override
@ -648,21 +640,23 @@ public class NotificationsFragment extends SFragment implements
updateAdapter(); updateAdapter();
// Execute clear notifications request // Execute clear notifications request
mastodonApi.clearNotificationsOld() timelineCases.clearNotificationsOld()
.observeOn(AndroidSchedulers.mainThread()) .subscribe(
.to(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY))) getViewLifecycleOwner(),
.subscribe( response -> {
response -> { // Nothing to do
// Nothing to do },
}, throwable -> {
throwable -> { // Reload notifications on failure
// Reload notifications on failure fullyRefreshWithProgressBar(true);
fullyRefreshWithProgressBar(true); });
});
} }
private void resetNotificationsLoad() { private void resetNotificationsLoad() {
disposables.clear(); for (Job job : jobs) {
job.cancel(null);
}
jobs.clear();
bottomLoading = false; bottomLoading = false;
topLoading = false; topLoading = false;
@ -797,15 +791,14 @@ public class NotificationsFragment extends SFragment implements
@Override @Override
public void onRespondToFollowRequest(boolean accept, String id, int position) { public void onRespondToFollowRequest(boolean accept, String id, int position) {
Single<Relationship> request = accept ? final Single<Relationship> request = accept ?
mastodonApi.authorizeFollowRequest(id) : timelineCases.acceptFollowRequestOld(id) :
mastodonApi.rejectFollowRequest(id); timelineCases.rejectFollowRequestOld(id);
request.observeOn(AndroidSchedulers.mainThread()) request.subscribe(
.to(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY))) getViewLifecycleOwner(),
.subscribe( (relationship) -> fullyRefreshWithProgressBar(true),
(relationship) -> fullyRefreshWithProgressBar(true), (error) -> Log.e(TAG, String.format("Failed to %s account id %s", accept ? "accept" : "reject", id))
(error) -> Log.e(TAG, String.format("Failed to %s account id %s", accept ? "accept" : "reject", id)) );
);
} }
@Override @Override
@ -927,20 +920,20 @@ public class NotificationsFragment extends SFragment implements
bottomLoading = true; bottomLoading = true;
} }
Disposable notificationCall = mastodonApi.notificationsOld(fromId, uptoId, LOAD_AT_ONCE, showNotificationsFilter ? notificationFilter : null) Job notificationCall = timelineCases.notificationsOld(fromId, uptoId, LOAD_AT_ONCE, showNotificationsFilter ? notificationFilter : null)
.observeOn(AndroidSchedulers.mainThread()) .subscribe(
.to(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY))) getViewLifecycleOwner(),
.subscribe( response -> {
response -> { if (response.isSuccessful()) {
if (response.isSuccessful()) { String linkHeader = response.headers().get("Link");
String linkHeader = response.headers().get("Link"); onFetchNotificationsSuccess(response.body(), linkHeader, fetchEnd, pos);
onFetchNotificationsSuccess(response.body(), linkHeader, fetchEnd, pos); } else {
} else { onFetchNotificationsFailure(new Exception(response.message()), fetchEnd, pos);
onFetchNotificationsFailure(new Exception(response.message()), fetchEnd, pos); }
} },
}, throwable -> onFetchNotificationsFailure(throwable, fetchEnd, pos)
throwable -> onFetchNotificationsFailure(throwable, fetchEnd, pos)); );
disposables.add(notificationCall); jobs.add(notificationCall);
} }
private void onFetchNotificationsSuccess(List<Notification> notifications, String linkHeader, private void onFetchNotificationsSuccess(List<Notification> notifications, String linkHeader,
@ -1250,26 +1243,6 @@ public class NotificationsFragment extends SFragment implements
loadNotificationsFilter(); loadNotificationsFilter();
fullyRefreshWithProgressBar(true); fullyRefreshWithProgressBar(true);
} }
startUpdateTimestamp();
}
/**
* Start to update adapter every minute to refresh timestamp
* If setting absoluteTimeView is false
* Auto dispose observable on pause
*/
private void startUpdateTimestamp() {
SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(requireContext());
boolean useAbsoluteTime = preferences.getBoolean("absoluteTimeView", false);
if (!useAbsoluteTime) {
Observable.interval(0, 1, TimeUnit.MINUTES)
.observeOn(AndroidSchedulers.mainThread())
.to(autoDisposable(from(this, Lifecycle.Event.ON_PAUSE)))
.subscribe(
interval -> updateAdapter()
);
}
} }
@Override @Override

View File

@ -30,6 +30,7 @@ import android.view.ViewGroup
import android.widget.ImageView import android.widget.ImageView
import androidx.core.os.BundleCompat import androidx.core.os.BundleCompat
import androidx.core.view.GestureDetectorCompat import androidx.core.view.GestureDetectorCompat
import androidx.lifecycle.lifecycleScope
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.bumptech.glide.load.DataSource import com.bumptech.glide.load.DataSource
import com.bumptech.glide.load.engine.GlideException import com.bumptech.glide.load.engine.GlideException
@ -44,8 +45,9 @@ import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.util.visible import com.keylesspalace.tusky.util.visible
import com.ortiz.touchview.OnTouchCoordinatesListener import com.ortiz.touchview.OnTouchCoordinatesListener
import com.ortiz.touchview.TouchImageView import com.ortiz.touchview.TouchImageView
import io.reactivex.rxjava3.subjects.BehaviorSubject
import kotlin.math.abs import kotlin.math.abs
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.launch
class ViewImageFragment : ViewMediaFragment() { class ViewImageFragment : ViewMediaFragment() {
interface PhotoActionsListener { interface PhotoActionsListener {
@ -58,7 +60,7 @@ class ViewImageFragment : ViewMediaFragment() {
private lateinit var photoActionsListener: PhotoActionsListener private lateinit var photoActionsListener: PhotoActionsListener
private lateinit var toolbar: View private lateinit var toolbar: View
private var transition = BehaviorSubject.create<Unit>() private var transition: CompletableDeferred<Unit>? = null
private var shouldStartTransition = false private var shouldStartTransition = false
// Volatile: Image requests happen on background thread and we want to see updates to it // Volatile: Image requests happen on background thread and we want to see updates to it
@ -91,7 +93,7 @@ class ViewImageFragment : ViewMediaFragment() {
savedInstanceState: Bundle? savedInstanceState: Bundle?
): View { ): View {
toolbar = (requireActivity() as ViewMediaActivity).toolbar toolbar = (requireActivity() as ViewMediaActivity).toolbar
this.transition = BehaviorSubject.create() this.transition = CompletableDeferred()
return inflater.inflate(R.layout.fragment_view_image, container, false) return inflater.inflate(R.layout.fragment_view_image, container, false)
} }
@ -246,7 +248,7 @@ class ViewImageFragment : ViewMediaFragment() {
} }
override fun onDestroyView() { override fun onDestroyView() {
transition.onComplete() transition = null
super.onDestroyView() super.onDestroyView()
} }
@ -345,19 +347,20 @@ class ViewImageFragment : ViewMediaFragment() {
if (shouldStartTransition) photoActionsListener.onBringUp() if (shouldStartTransition) photoActionsListener.onBringUp()
} }
} else { } else {
// This wait for transition. If there's no transition then we should hit // This waits for transition. If there's no transition then we should hit
// another branch. take() will unsubscribe after we have it to not leak memory // another branch. When the view is destroyed the coroutine is automatically canceled.
transition transition?.let {
.take(1) viewLifecycleOwner.lifecycleScope.launch {
.subscribe { it.await()
target.onResourceReady(resource, null) target.onResourceReady(resource, null)
} }
}
} }
return true return true
} }
} }
override fun onTransitionEnd() { override fun onTransitionEnd() {
this.transition.onNext(Unit) this.transition?.complete(Unit)
} }
} }

View File

@ -46,7 +46,6 @@ import com.keylesspalace.tusky.entity.StatusEdit
import com.keylesspalace.tusky.entity.StatusSource import com.keylesspalace.tusky.entity.StatusSource
import com.keylesspalace.tusky.entity.TimelineAccount import com.keylesspalace.tusky.entity.TimelineAccount
import com.keylesspalace.tusky.entity.TrendingTag import com.keylesspalace.tusky.entity.TrendingTag
import io.reactivex.rxjava3.core.Single
import okhttp3.MultipartBody import okhttp3.MultipartBody
import okhttp3.RequestBody import okhttp3.RequestBody
import okhttp3.ResponseBody import okhttp3.ResponseBody
@ -139,22 +138,14 @@ interface MastodonApi {
@GET("api/v1/notifications") @GET("api/v1/notifications")
suspend fun notifications( suspend fun notifications(
/** Return results older than this ID */ /** Return results older than this ID */
@Query("max_id") maxId: String? = null,
/** Return results immediately newer than this ID */
@Query("min_id") minId: String? = null,
/** Maximum number of results to return. Defaults to 15, max is 30 */
@Query("limit") limit: Int? = null,
/** Types to excludes from the results */
@Query("exclude_types[]") excludes: Set<Notification.Type>? = null
): Response<List<Notification>>
@GET("api/v1/notifications")
fun notificationsOld(
@Query("max_id") maxId: String?, @Query("max_id") maxId: String?,
/** Return results newer than this ID */
@Query("since_id") sinceId: String?, @Query("since_id") sinceId: String?,
/** Maximum number of results to return. Defaults to 15, max is 30 */
@Query("limit") limit: Int?, @Query("limit") limit: Int?,
/** Types to excludes from the results */
@Query("exclude_types[]") excludes: Set<Notification.Type>? @Query("exclude_types[]") excludes: Set<Notification.Type>?
): Single<Response<List<Notification>>> ): Response<List<Notification>>
/** Fetch a single notification */ /** Fetch a single notification */
@GET("api/v1/notifications/{id}") @GET("api/v1/notifications/{id}")
@ -185,10 +176,7 @@ interface MastodonApi {
): Response<List<Notification>> ): Response<List<Notification>>
@POST("api/v1/notifications/clear") @POST("api/v1/notifications/clear")
suspend fun clearNotifications(): Response<ResponseBody> suspend fun clearNotifications(): NetworkResult<ResponseBody>
@POST("api/v1/notifications/clear")
fun clearNotificationsOld(): Single<ResponseBody>
@FormUrlEncoded @FormUrlEncoded
@PUT("api/v1/media/{mediaId}") @PUT("api/v1/media/{mediaId}")
@ -221,9 +209,6 @@ interface MastodonApi {
@Body editedStatus: NewStatus @Body editedStatus: NewStatus
): NetworkResult<Status> ): NetworkResult<Status>
@GET("api/v1/statuses/{id}")
suspend fun statusAsync(@Path("id") statusId: String): NetworkResult<Status>
@GET("api/v1/statuses/{id}/source") @GET("api/v1/statuses/{id}/source")
suspend fun statusSource(@Path("id") statusId: String): NetworkResult<StatusSource> suspend fun statusSource(@Path("id") statusId: String): NetworkResult<StatusSource>
@ -266,24 +251,6 @@ interface MastodonApi {
@POST("api/v1/statuses/{id}/unbookmark") @POST("api/v1/statuses/{id}/unbookmark")
suspend fun unbookmarkStatus(@Path("id") statusId: String): NetworkResult<Status> suspend fun unbookmarkStatus(@Path("id") statusId: String): NetworkResult<Status>
@POST("api/v1/statuses/{id}/reblog")
fun reblogStatusOld(@Path("id") statusId: String): Single<Status>
@POST("api/v1/statuses/{id}/unreblog")
fun unreblogStatusOld(@Path("id") statusId: String): Single<Status>
@POST("api/v1/statuses/{id}/favourite")
fun favouriteStatusOld(@Path("id") statusId: String): Single<Status>
@POST("api/v1/statuses/{id}/unfavourite")
fun unfavouriteStatusOld(@Path("id") statusId: String): Single<Status>
@POST("api/v1/statuses/{id}/bookmark")
fun bookmarkStatusOld(@Path("id") statusId: String): Single<Status>
@POST("api/v1/statuses/{id}/unbookmark")
fun unbookmarkStatusOld(@Path("id") statusId: String): Single<Status>
@POST("api/v1/statuses/{id}/pin") @POST("api/v1/statuses/{id}/pin")
suspend fun pinStatus(@Path("id") statusId: String): NetworkResult<Status> suspend fun pinStatus(@Path("id") statusId: String): NetworkResult<Status>
@ -296,17 +263,11 @@ interface MastodonApi {
@POST("api/v1/statuses/{id}/unmute") @POST("api/v1/statuses/{id}/unmute")
suspend fun unmuteConversation(@Path("id") statusId: String): NetworkResult<Status> suspend fun unmuteConversation(@Path("id") statusId: String): NetworkResult<Status>
@POST("api/v1/statuses/{id}/mute")
fun muteConversationOld(@Path("id") statusId: String): Single<Status>
@POST("api/v1/statuses/{id}/unmute")
fun unmuteConversationOld(@Path("id") statusId: String): Single<Status>
@GET("api/v1/scheduled_statuses") @GET("api/v1/scheduled_statuses")
fun scheduledStatuses( suspend fun scheduledStatuses(
@Query("limit") limit: Int? = null, @Query("limit") limit: Int? = null,
@Query("max_id") maxId: String? = null @Query("max_id") maxId: String? = null
): Single<List<ScheduledStatus>> ): NetworkResult<List<ScheduledStatus>>
@DELETE("api/v1/scheduled_statuses/{id}") @DELETE("api/v1/scheduled_statuses/{id}")
suspend fun deleteScheduledStatus( suspend fun deleteScheduledStatus(
@ -353,14 +314,6 @@ interface MastodonApi {
@Query("following") following: Boolean? = null @Query("following") following: Boolean? = null
): NetworkResult<List<TimelineAccount>> ): NetworkResult<List<TimelineAccount>>
@GET("api/v1/accounts/search")
fun searchAccountsSync(
@Query("q") query: String,
@Query("resolve") resolve: Boolean? = null,
@Query("limit") limit: Int? = null,
@Query("following") following: Boolean? = null
): NetworkResult<List<TimelineAccount>>
@GET("api/v1/accounts/{id}") @GET("api/v1/accounts/{id}")
suspend fun account(@Path("id") accountId: String): NetworkResult<Account> suspend fun account(@Path("id") accountId: String): NetworkResult<Account>
@ -475,10 +428,10 @@ interface MastodonApi {
suspend fun followRequests(@Query("max_id") maxId: String?): Response<List<TimelineAccount>> suspend fun followRequests(@Query("max_id") maxId: String?): Response<List<TimelineAccount>>
@POST("api/v1/follow_requests/{id}/authorize") @POST("api/v1/follow_requests/{id}/authorize")
fun authorizeFollowRequest(@Path("id") accountId: String): Single<Relationship> suspend fun authorizeFollowRequest(@Path("id") accountId: String): NetworkResult<Relationship>
@POST("api/v1/follow_requests/{id}/reject") @POST("api/v1/follow_requests/{id}/reject")
fun rejectFollowRequest(@Path("id") accountId: String): Single<Relationship> suspend fun rejectFollowRequest(@Path("id") accountId: String): NetworkResult<Relationship>
@FormUrlEncoded @FormUrlEncoded
@POST("api/v1/apps") @POST("api/v1/apps")
@ -641,10 +594,6 @@ interface MastodonApi {
@Field("choices[]") choices: List<Int> @Field("choices[]") choices: List<Int>
): NetworkResult<Poll> ): NetworkResult<Poll>
@FormUrlEncoded
@POST("api/v1/polls/{id}/votes")
fun voteInPollOld(@Path("id") id: String, @Field("choices[]") choices: List<Int>): Single<Poll>
@GET("api/v1/announcements") @GET("api/v1/announcements")
suspend fun listAnnouncements( suspend fun listAnnouncements(
@Query("with_dismissed") withDismissed: Boolean = true @Query("with_dismissed") withDismissed: Boolean = true
@ -675,30 +624,17 @@ interface MastodonApi {
): NetworkResult<Unit> ): NetworkResult<Unit>
@GET("api/v1/accounts/{id}/statuses") @GET("api/v1/accounts/{id}/statuses")
fun accountStatusesObservable( suspend fun accountStatuses(
@Path("id") accountId: String, @Path("id") accountId: String,
@Query("max_id") maxId: String?, @Query("max_id") maxId: String?,
@Query("since_id") sinceId: String?, @Query("since_id") sinceId: String?,
@Query("min_id") minId: String?, @Query("min_id") minId: String?,
@Query("limit") limit: Int?, @Query("limit") limit: Int?,
@Query("exclude_reblogs") excludeReblogs: Boolean? @Query("exclude_reblogs") excludeReblogs: Boolean?
): Single<List<Status>> ): NetworkResult<List<Status>>
@GET("api/v1/statuses/{id}")
fun statusObservable(@Path("id") statusId: String): Single<Status>
@GET("api/v2/search") @GET("api/v2/search")
fun searchObservable( suspend fun search(
@Query("q") query: String?,
@Query("type") type: String? = null,
@Query("resolve") resolve: Boolean? = null,
@Query("limit") limit: Int? = null,
@Query("offset") offset: Int? = null,
@Query("following") following: Boolean? = null
): Single<SearchResult>
@GET("api/v2/search")
fun searchSync(
@Query("q") query: String?, @Query("q") query: String?,
@Query("type") type: String? = null, @Query("type") type: String? = null,
@Query("resolve") resolve: Boolean? = null, @Query("resolve") resolve: Boolean? = null,

View File

@ -24,11 +24,12 @@ import com.keylesspalace.tusky.components.notifications.canEnablePushNotificatio
import com.keylesspalace.tusky.components.notifications.isUnifiedPushNotificationEnabledForAccount import com.keylesspalace.tusky.components.notifications.isUnifiedPushNotificationEnabledForAccount
import com.keylesspalace.tusky.components.notifications.updateUnifiedPushSubscription import com.keylesspalace.tusky.components.notifications.updateUnifiedPushSubscription
import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.di.ApplicationScope
import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MastodonApi
import dagger.android.AndroidInjection import dagger.android.AndroidInjection
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@DelicateCoroutinesApi @DelicateCoroutinesApi
@ -39,6 +40,10 @@ class NotificationBlockStateBroadcastReceiver : BroadcastReceiver() {
@Inject @Inject
lateinit var accountManager: AccountManager lateinit var accountManager: AccountManager
@Inject
@ApplicationScope
lateinit var externalScope: CoroutineScope
override fun onReceive(context: Context, intent: Intent) { override fun onReceive(context: Context, intent: Intent) {
AndroidInjection.inject(this, context) AndroidInjection.inject(this, context)
if (Build.VERSION.SDK_INT < 28) return if (Build.VERSION.SDK_INT < 28) return
@ -60,7 +65,7 @@ class NotificationBlockStateBroadcastReceiver : BroadcastReceiver() {
accountManager.getAccountByIdentifier(gid)?.let { account -> accountManager.getAccountByIdentifier(gid)?.let { account ->
if (isUnifiedPushNotificationEnabledForAccount(account)) { if (isUnifiedPushNotificationEnabledForAccount(account)) {
// Update UnifiedPush notification subscription // Update UnifiedPush notification subscription
GlobalScope.launch { externalScope.launch {
updateUnifiedPushSubscription( updateUnifiedPushSubscription(
context, context,
mastodonApi, mastodonApi,

View File

@ -23,12 +23,13 @@ import androidx.work.WorkManager
import com.keylesspalace.tusky.components.notifications.registerUnifiedPushEndpoint import com.keylesspalace.tusky.components.notifications.registerUnifiedPushEndpoint
import com.keylesspalace.tusky.components.notifications.unregisterUnifiedPushEndpoint import com.keylesspalace.tusky.components.notifications.unregisterUnifiedPushEndpoint
import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.di.ApplicationScope
import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.worker.NotificationWorker import com.keylesspalace.tusky.worker.NotificationWorker
import dagger.android.AndroidInjection import dagger.android.AndroidInjection
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.unifiedpush.android.connector.MessagingReceiver import org.unifiedpush.android.connector.MessagingReceiver
@ -44,6 +45,10 @@ class UnifiedPushBroadcastReceiver : MessagingReceiver() {
@Inject @Inject
lateinit var mastodonApi: MastodonApi lateinit var mastodonApi: MastodonApi
@Inject
@ApplicationScope
lateinit var externalScope: CoroutineScope
override fun onReceive(context: Context, intent: Intent) { override fun onReceive(context: Context, intent: Intent) {
super.onReceive(context, intent) super.onReceive(context, intent)
AndroidInjection.inject(this, context) AndroidInjection.inject(this, context)
@ -61,9 +66,7 @@ class UnifiedPushBroadcastReceiver : MessagingReceiver() {
AndroidInjection.inject(this, context) AndroidInjection.inject(this, context)
Log.d(TAG, "Endpoint available for account $instance: $endpoint") Log.d(TAG, "Endpoint available for account $instance: $endpoint")
accountManager.getAccountById(instance.toLong())?.let { accountManager.getAccountById(instance.toLong())?.let {
// Launch the coroutine in global scope -- it is short and we don't want to lose the registration event externalScope.launch {
// and there is no saner way to use structured concurrency in a receiver
GlobalScope.launch {
registerUnifiedPushEndpoint(context, mastodonApi, accountManager, it, endpoint) registerUnifiedPushEndpoint(context, mastodonApi, accountManager, it, endpoint)
} }
} }
@ -76,7 +79,7 @@ class UnifiedPushBroadcastReceiver : MessagingReceiver() {
Log.d(TAG, "Endpoint unregistered for account $instance") Log.d(TAG, "Endpoint unregistered for account $instance")
accountManager.getAccountById(instance.toLong())?.let { accountManager.getAccountById(instance.toLong())?.let {
// It's fine if the account does not exist anymore -- that means it has been logged out // It's fine if the account does not exist anymore -- that means it has been logged out
GlobalScope.launch { unregisterUnifiedPushEndpoint(mastodonApi, accountManager, it) } externalScope.launch { unregisterUnifiedPushEndpoint(mastodonApi, accountManager, it) }
} }
} }
} }

View File

@ -7,7 +7,7 @@ import com.keylesspalace.tusky.components.notifications.disableUnifiedPushNotifi
import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.AppDatabase import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.removeShortcut import com.keylesspalace.tusky.util.ShareShortcutHelper
import javax.inject.Inject import javax.inject.Inject
class LogoutUsecase @Inject constructor( class LogoutUsecase @Inject constructor(
@ -15,7 +15,8 @@ class LogoutUsecase @Inject constructor(
private val api: MastodonApi, private val api: MastodonApi,
private val db: AppDatabase, private val db: AppDatabase,
private val accountManager: AccountManager, private val accountManager: AccountManager,
private val draftHelper: DraftHelper private val draftHelper: DraftHelper,
private val shareShortcutHelper: ShareShortcutHelper
) { ) {
/** /**
@ -57,7 +58,7 @@ class LogoutUsecase @Inject constructor(
draftHelper.deleteAllDraftsAndAttachmentsForAccount(activeAccount.id) draftHelper.deleteAllDraftsAndAttachmentsForAccount(activeAccount.id)
// remove shortcut associated with the account // remove shortcut associated with the account
removeShortcut(context, activeAccount) shareShortcutHelper.removeShortcut(activeAccount)
return otherAccountAvailable return otherAccountAvailable
} }

View File

@ -20,6 +20,7 @@ import at.connyduck.calladapter.networkresult.NetworkResult
import at.connyduck.calladapter.networkresult.fold import at.connyduck.calladapter.networkresult.fold
import at.connyduck.calladapter.networkresult.onFailure import at.connyduck.calladapter.networkresult.onFailure
import at.connyduck.calladapter.networkresult.onSuccess import at.connyduck.calladapter.networkresult.onSuccess
import at.connyduck.calladapter.networkresult.runCatching
import com.keylesspalace.tusky.appstore.BlockEvent import com.keylesspalace.tusky.appstore.BlockEvent
import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.MuteConversationEvent import com.keylesspalace.tusky.appstore.MuteConversationEvent
@ -28,13 +29,16 @@ import com.keylesspalace.tusky.appstore.PollVoteEvent
import com.keylesspalace.tusky.appstore.StatusChangedEvent import com.keylesspalace.tusky.appstore.StatusChangedEvent
import com.keylesspalace.tusky.appstore.StatusDeletedEvent import com.keylesspalace.tusky.appstore.StatusDeletedEvent
import com.keylesspalace.tusky.entity.DeletedStatus import com.keylesspalace.tusky.entity.DeletedStatus
import com.keylesspalace.tusky.entity.Notification
import com.keylesspalace.tusky.entity.Poll import com.keylesspalace.tusky.entity.Poll
import com.keylesspalace.tusky.entity.Relationship import com.keylesspalace.tusky.entity.Relationship
import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.Single
import com.keylesspalace.tusky.util.getServerErrorMessage import com.keylesspalace.tusky.util.getServerErrorMessage
import io.reactivex.rxjava3.core.Single
import javax.inject.Inject import javax.inject.Inject
import okhttp3.ResponseBody
import retrofit2.Response
/** /**
* Created by charlag on 3/24/18. * Created by charlag on 3/24/18.
@ -61,6 +65,10 @@ class TimelineCases @Inject constructor(
} }
} }
fun reblogOld(statusId: String, reblog: Boolean): Single<Status> {
return Single { reblog(statusId, reblog) }
}
suspend fun favourite(statusId: String, favourite: Boolean): NetworkResult<Status> { suspend fun favourite(statusId: String, favourite: Boolean): NetworkResult<Status> {
return if (favourite) { return if (favourite) {
mastodonApi.favouriteStatus(statusId) mastodonApi.favouriteStatus(statusId)
@ -71,6 +79,10 @@ class TimelineCases @Inject constructor(
} }
} }
fun favouriteOld(statusId: String, favourite: Boolean): Single<Status> {
return Single { favourite(statusId, favourite) }
}
suspend fun bookmark(statusId: String, bookmark: Boolean): NetworkResult<Status> { suspend fun bookmark(statusId: String, bookmark: Boolean): NetworkResult<Status> {
return if (bookmark) { return if (bookmark) {
mastodonApi.bookmarkStatus(statusId) mastodonApi.bookmarkStatus(statusId)
@ -81,6 +93,10 @@ class TimelineCases @Inject constructor(
} }
} }
fun bookmarkOld(statusId: String, bookmark: Boolean): Single<Status> {
return Single { bookmark(statusId, bookmark) }
}
suspend fun muteConversation(statusId: String, mute: Boolean): NetworkResult<Status> { suspend fun muteConversation(statusId: String, mute: Boolean): NetworkResult<Status> {
return if (mute) { return if (mute) {
mastodonApi.muteConversation(statusId) mastodonApi.muteConversation(statusId)
@ -91,50 +107,6 @@ class TimelineCases @Inject constructor(
} }
} }
fun reblogOld(statusId: String, reblog: Boolean): Single<Status> {
val call = if (reblog) {
mastodonApi.reblogStatusOld(statusId)
} else {
mastodonApi.unreblogStatusOld(statusId)
}
return call.doAfterSuccess { status ->
eventHub.dispatchOld(StatusChangedEvent(status))
}
}
fun favouriteOld(statusId: String, favourite: Boolean): Single<Status> {
val call = if (favourite) {
mastodonApi.favouriteStatusOld(statusId)
} else {
mastodonApi.unfavouriteStatusOld(statusId)
}
return call.doAfterSuccess { status ->
eventHub.dispatchOld(StatusChangedEvent(status))
}
}
fun bookmarkOld(statusId: String, bookmark: Boolean): Single<Status> {
val call = if (bookmark) {
mastodonApi.bookmarkStatusOld(statusId)
} else {
mastodonApi.unbookmarkStatusOld(statusId)
}
return call.doAfterSuccess { status ->
eventHub.dispatchOld(StatusChangedEvent(status))
}
}
fun muteConversationOld(statusId: String, mute: Boolean): Single<Status> {
val call = if (mute) {
mastodonApi.muteConversationOld(statusId)
} else {
mastodonApi.unmuteConversationOld(statusId)
}
return call.doAfterSuccess {
eventHub.dispatchOld(MuteConversationEvent(statusId, mute))
}
}
suspend fun mute(statusId: String, notifications: Boolean, duration: Int?) { suspend fun mute(statusId: String, notifications: Boolean, duration: Int?) {
try { try {
mastodonApi.muteAccount(statusId, notifications, duration) mastodonApi.muteAccount(statusId, notifications, duration)
@ -188,21 +160,28 @@ class TimelineCases @Inject constructor(
} }
fun voteInPollOld(statusId: String, pollId: String, choices: List<Int>): Single<Poll> { fun voteInPollOld(statusId: String, pollId: String, choices: List<Int>): Single<Poll> {
if (choices.isEmpty()) { return Single { voteInPoll(statusId, pollId, choices) }
return Single.error(IllegalStateException())
}
return mastodonApi.voteInPollOld(pollId, choices).doAfterSuccess {
eventHub.dispatchOld(PollVoteEvent(statusId, it))
}
} }
fun acceptFollowRequest(accountId: String): Single<Relationship> { fun acceptFollowRequestOld(accountId: String): Single<Relationship> {
return mastodonApi.authorizeFollowRequest(accountId) return Single { mastodonApi.authorizeFollowRequest(accountId) }
} }
fun rejectFollowRequest(accountId: String): Single<Relationship> { fun rejectFollowRequestOld(accountId: String): Single<Relationship> {
return mastodonApi.rejectFollowRequest(accountId) return Single { mastodonApi.rejectFollowRequest(accountId) }
}
fun notificationsOld(
maxId: String?,
sinceId: String?,
limit: Int?,
excludes: Set<Notification.Type>?
): Single<Response<List<Notification>>> {
return Single { runCatching { mastodonApi.notifications(maxId, sinceId, limit, excludes) } }
}
fun clearNotificationsOld(): Single<ResponseBody> {
return Single { mastodonApi.clearNotifications() }
} }
companion object { companion object {

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.IOException
import java.io.InputStream import java.io.InputStream
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Date import java.util.Date
import java.util.Locale import java.util.Locale
import kotlin.time.Duration.Companion.hours
/** /**
* Helper methods for obtaining and resizing media files * Helper methods for obtaining and resizing media files
@ -179,12 +179,10 @@ fun deleteStaleCachedMedia(mediaDirectory: File?) {
return return
} }
val twentyfourHoursAgo = Calendar.getInstance() val unixTime = System.currentTimeMillis() - 24.hours.inWholeMilliseconds
twentyfourHoursAgo.add(Calendar.HOUR, -24)
val unixTime = twentyfourHoursAgo.timeInMillis
val files = mediaDirectory.listFiles { file -> unixTime > file.lastModified() && file.name.contains(MEDIA_TEMP_PREFIX) } 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 // Nothing to do
return 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.MainActivity
import com.keylesspalace.tusky.R import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.db.AccountEntity import com.keylesspalace.tusky.db.AccountEntity
import io.reactivex.rxjava3.core.Single import com.keylesspalace.tusky.di.ApplicationScope
import io.reactivex.rxjava3.schedulers.Schedulers import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
fun updateShortcut(context: Context, account: AccountEntity) { class ShareShortcutHelper @Inject constructor(
Single.fromCallable { private val context: Context,
val innerSize = context.resources.getDimensionPixelSize(R.dimen.adaptive_bitmap_inner_size) @ApplicationScope private val externalScope: CoroutineScope
val outerSize = context.resources.getDimensionPixelSize(R.dimen.adaptive_bitmap_outer_size) ) {
val bmp = if (TextUtils.isEmpty(account.profilePictureUrl)) { fun updateShortcut(account: AccountEntity) {
Glide.with(context) externalScope.launch {
.asBitmap() val innerSize = context.resources.getDimensionPixelSize(R.dimen.adaptive_bitmap_inner_size)
.load(R.drawable.avatar_default) val outerSize = context.resources.getDimensionPixelSize(R.dimen.adaptive_bitmap_outer_size)
.submit(innerSize, innerSize)
.get() val bmp = if (TextUtils.isEmpty(account.profilePictureUrl)) {
} else { Glide.with(context)
Glide.with(context) .asBitmap()
.asBitmap() .load(R.drawable.avatar_default)
.load(account.profilePictureUrl) .submitAsync(innerSize, innerSize)
.error(R.drawable.avatar_default) } else {
.submit(innerSize, innerSize) Glide.with(context)
.get() .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) { fun removeShortcut(account: AccountEntity) {
ShortcutManagerCompat.removeDynamicShortcuts(context, listOf(account.id.toString())) 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) }
)
}
}
}

View File

@ -124,26 +124,6 @@
license:link="https://google.github.io/dagger/" license:link="https://google.github.io/dagger/"
license:name="Dagger 2" /> license:name="Dagger 2" />
<com.keylesspalace.tusky.view.LicenseCard
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginEnd="12dp"
android:layout_marginStart="12dp"
android:layout_marginTop="12dp"
license:license="@string/license_apache_2"
license:link="https://github.com/ReactiveX/RxJava"
license:name="RxJava" />
<com.keylesspalace.tusky.view.LicenseCard
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginEnd="12dp"
android:layout_marginStart="12dp"
android:layout_marginTop="12dp"
license:license="@string/license_apache_2"
license:link="https://github.com/uber/AutoDispose"
license:name="AutoDispose" />
<com.keylesspalace.tusky.view.LicenseCard <com.keylesspalace.tusky.view.LicenseCard
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"

View File

@ -16,16 +16,19 @@
package com.keylesspalace.tusky package com.keylesspalace.tusky
import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import at.connyduck.calladapter.networkresult.NetworkResult
import com.keylesspalace.tusky.entity.SearchResult import com.keylesspalace.tusky.entity.SearchResult
import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.entity.TimelineAccount import com.keylesspalace.tusky.entity.TimelineAccount
import com.keylesspalace.tusky.network.MastodonApi 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.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.assertEquals
import org.junit.Assert.assertFalse import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
@ -37,6 +40,7 @@ import org.mockito.Mockito.eq
import org.mockito.kotlin.doReturn import org.mockito.kotlin.doReturn
import org.mockito.kotlin.mock import org.mockito.kotlin.mock
@OptIn(ExperimentalCoroutinesApi::class)
class BottomSheetActivityTest { class BottomSheetActivityTest {
@get:Rule @get:Rule
@ -48,8 +52,7 @@ class BottomSheetActivityTest {
private val statusQuery = "http://mastodon.foo.bar/@User/345678" private val statusQuery = "http://mastodon.foo.bar/@User/345678"
private val nonexistentStatusQuery = "http://mastodon.foo.bar/@User/345678000" private val nonexistentStatusQuery = "http://mastodon.foo.bar/@User/345678000"
private val nonMastodonQuery = "http://medium.com/@correspondent/345678" private val nonMastodonQuery = "http://medium.com/@correspondent/345678"
private val emptyCallback = Single.just(SearchResult(emptyList(), emptyList(), emptyList())) private val emptyResult = NetworkResult.success(SearchResult(emptyList(), emptyList(), emptyList()))
private val testScheduler = TestScheduler()
private val account = TimelineAccount( private val account = TimelineAccount(
id = "1", id = "1",
@ -60,7 +63,7 @@ class BottomSheetActivityTest {
url = "http://mastodon.foo.bar/@User", url = "http://mastodon.foo.bar/@User",
avatar = "" 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( private val status = Status(
id = "1", id = "1",
@ -93,18 +96,15 @@ class BottomSheetActivityTest {
language = null, language = null,
filtered = null filtered = null
) )
private val statusSingle = Single.just(SearchResult(emptyList(), listOf(status), emptyList())) private val statusResult = NetworkResult.success(SearchResult(emptyList(), listOf(status), emptyList()))
@Before @Before
fun setup() { fun setup() {
RxJavaPlugins.setIoSchedulerHandler { testScheduler }
RxAndroidPlugins.setMainThreadSchedulerHandler { testScheduler }
apiMock = mock { apiMock = mock {
on { searchObservable(eq(accountQuery), eq(null), anyBoolean(), eq(null), eq(null), eq(null)) } doReturn accountSingle onBlocking { search(eq(accountQuery), eq(null), anyBoolean(), eq(null), eq(null), eq(null)) } doReturn accountResult
on { searchObservable(eq(statusQuery), eq(null), anyBoolean(), eq(null), eq(null), eq(null)) } doReturn statusSingle onBlocking { search(eq(statusQuery), eq(null), anyBoolean(), eq(null), eq(null), eq(null)) } doReturn statusResult
on { searchObservable(eq(nonexistentStatusQuery), eq(null), anyBoolean(), eq(null), eq(null), eq(null)) } doReturn accountSingle onBlocking { search(eq(nonexistentStatusQuery), eq(null), anyBoolean(), eq(null), eq(null), eq(null)) } doReturn accountResult
on { searchObservable(eq(nonMastodonQuery), eq(null), anyBoolean(), eq(null), eq(null), eq(null)) } doReturn emptyCallback onBlocking { search(eq(nonMastodonQuery), eq(null), anyBoolean(), eq(null), eq(null), eq(null)) } doReturn emptyResult
} }
activity = FakeBottomSheetActivity(apiMock) activity = FakeBottomSheetActivity(apiMock)
@ -157,86 +157,134 @@ class BottomSheetActivityTest {
} }
@Test @Test
fun search_inIdealConditions_returnsRequestedResults_forAccount() { fun search_inIdealConditions_returnsRequestedResults_forAccount() = runTest {
activity.viewUrl(accountQuery) Dispatchers.setMain(StandardTestDispatcher(testScheduler))
testScheduler.advanceTimeBy(100, TimeUnit.MILLISECONDS) try {
assertEquals(account.id, activity.accountId) activity.viewUrl(accountQuery)
} testScheduler.advanceTimeBy(100.milliseconds)
assertEquals(account.id, activity.accountId)
@Test } finally {
fun search_inIdealConditions_returnsRequestedResults_forStatus() { Dispatchers.resetMain()
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)
} }
} }
@Test @Test
fun search_doesNotRespectUnrelatedResult() { fun search_inIdealConditions_returnsRequestedResults_forStatus() = runTest {
activity.viewUrl(nonexistentStatusQuery) Dispatchers.setMain(StandardTestDispatcher(testScheduler))
testScheduler.advanceTimeBy(100, TimeUnit.MILLISECONDS) try {
assertEquals(nonexistentStatusQuery, activity.link) activity.viewUrl(statusQuery)
assertEquals(null, activity.accountId) testScheduler.advanceTimeBy(100.milliseconds)
assertEquals(status.id, activity.statusId)
} finally {
Dispatchers.resetMain()
}
} }
@Test @Test
fun search_withCancellation_doesNotLoadUrl_forAccount() { fun search_inIdealConditions_returnsRequestedResults_forNonMastodonURL() = runTest {
activity.viewUrl(accountQuery) Dispatchers.setMain(StandardTestDispatcher(testScheduler))
assertTrue(activity.isSearching()) try {
activity.cancelActiveSearch() activity.viewUrl(nonMastodonQuery)
assertFalse(activity.isSearching()) testScheduler.advanceTimeBy(100.milliseconds)
assertEquals(null, activity.accountId) assertEquals(nonMastodonQuery, activity.link)
} finally {
Dispatchers.resetMain()
}
} }
@Test @Test
fun search_withCancellation_doesNotLoadUrl_forStatus() { fun search_withNoResults_appliesRequestedFallbackBehavior() = runTest {
activity.viewUrl(accountQuery) Dispatchers.setMain(StandardTestDispatcher(testScheduler))
activity.cancelActiveSearch() try {
assertEquals(null, activity.accountId) 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 @Test
fun search_withCancellation_doesNotLoadUrl_forNonMastodonURL() { fun search_doesNotRespectUnrelatedResult() = runTest {
activity.viewUrl(nonMastodonQuery) Dispatchers.setMain(StandardTestDispatcher(testScheduler))
activity.cancelActiveSearch() try {
assertEquals(null, activity.searchUrl) activity.viewUrl(nonexistentStatusQuery)
testScheduler.advanceTimeBy(100.milliseconds)
assertEquals(nonexistentStatusQuery, activity.link)
assertEquals(null, activity.accountId)
} finally {
Dispatchers.resetMain()
}
} }
@Test @Test
fun search_withPreviousCancellation_completes() { fun search_withCancellation_doesNotLoadUrl_forAccount() = runTest {
// begin/cancel account search Dispatchers.setMain(StandardTestDispatcher(testScheduler))
activity.viewUrl(accountQuery) try {
activity.cancelActiveSearch() activity.viewUrl(accountQuery)
assertTrue(activity.isSearching())
activity.cancelActiveSearch()
assertFalse(activity.isSearching())
assertEquals(null, activity.accountId)
} finally {
Dispatchers.resetMain()
}
}
// begin status search @Test
activity.viewUrl(statusQuery) 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 @Test
assertTrue(activity.isSearching()) 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 @Test
testScheduler.advanceTimeBy(100, TimeUnit.MILLISECONDS) 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 // begin status search
// and the account search wasn't activity.viewUrl(statusQuery)
assertEquals(status.id, activity.statusId)
assertEquals(null, activity.accountId) // 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() { 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.Notification
import com.keylesspalace.tusky.entity.TimelineAccount import com.keylesspalace.tusky.entity.TimelineAccount
import java.util.Date import java.util.Date
import kotlinx.coroutines.test.TestScope
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull import org.junit.Assert.assertNotNull
import org.junit.Before import org.junit.Before
@ -126,6 +127,8 @@ class MainActivityTest {
on { activeAccount } doReturn accountEntity on { activeAccount } doReturn accountEntity
} }
activity.draftsAlert = mock {} activity.draftsAlert = mock {}
activity.shareShortcutHelper = mock {}
activity.externalScope = TestScope()
activity.mastodonApi = mock { activity.mastodonApi = mock {
onBlocking { accountVerifyCredentials() } doReturn NetworkResult.success(account) onBlocking { accountVerifyCredentials() } doReturn NetworkResult.success(account)
onBlocking { listAnnouncements(false) } doReturn NetworkResult.success(emptyList()) onBlocking { listAnnouncements(false) } doReturn NetworkResult.success(emptyList())

View File

@ -21,7 +21,6 @@ androidx-testing = "2.2.0"
androidx-viewpager2 = "1.0.0" androidx-viewpager2 = "1.0.0"
androidx-work = "2.9.0" androidx-work = "2.9.0"
androidx-room = "2.6.1" androidx-room = "2.6.1"
autodispose = "2.2.1"
bouncycastle = "1.70" bouncycastle = "1.70"
conscrypt = "2.5.2" conscrypt = "2.5.2"
coroutines = "1.8.0" coroutines = "1.8.0"
@ -45,9 +44,6 @@ networkresult-calladapter = "1.1.0"
okhttp = "4.12.0" okhttp = "4.12.0"
retrofit = "2.9.0" retrofit = "2.9.0"
robolectric = "4.11.1" robolectric = "4.11.1"
rxandroid3 = "3.0.2"
rxjava3 = "3.1.8"
rxkotlin3 = "3.0.1"
sparkbutton = "4.2.0" sparkbutton = "4.2.0"
touchimageview = "3.6" touchimageview = "3.6"
truth = "1.4.1" truth = "1.4.1"
@ -101,8 +97,6 @@ androidx-test-junit = { module = "androidx.test.ext:junit", version.ref = "andro
androidx-viewpager2 = { module = "androidx.viewpager2:viewpager2", version.ref = "androidx-viewpager2" } androidx-viewpager2 = { module = "androidx.viewpager2:viewpager2", version.ref = "androidx-viewpager2" }
androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "androidx-work" } androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "androidx-work" }
androidx-work-testing = { module = "androidx.work:work-testing", version.ref = "androidx-work" } androidx-work-testing = { module = "androidx.work:work-testing", version.ref = "androidx-work" }
autodispose-android-lifecycle = { module = "com.uber.autodispose2:autodispose-androidx-lifecycle", version.ref = "autodispose" }
autodispose-core = { module = "com.uber.autodispose2:autodispose", version.ref = "autodispose" }
bouncycastle = { module = "org.bouncycastle:bcprov-jdk15on", version.ref = "bouncycastle" } bouncycastle = { module = "org.bouncycastle:bcprov-jdk15on", version.ref = "bouncycastle" }
conscrypt-android = { module = "org.conscrypt:conscrypt-android", version.ref = "conscrypt" } conscrypt-android = { module = "org.conscrypt:conscrypt-android", version.ref = "conscrypt" }
dagger-android-core = { module = "com.google.dagger:dagger-android", version.ref = "dagger" } dagger-android-core = { module = "com.google.dagger:dagger-android", version.ref = "dagger" }
@ -121,7 +115,6 @@ glide-core = { module = "com.github.bumptech.glide:glide", version.ref = "glide"
glide-okhttp3-integration = { module = "com.github.bumptech.glide:okhttp3-integration", version.ref = "glide" } glide-okhttp3-integration = { module = "com.github.bumptech.glide:okhttp3-integration", version.ref = "glide" }
gson = { module = "com.google.code.gson:gson", version.ref = "gson" } gson = { module = "com.google.code.gson:gson", version.ref = "gson" }
kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" } kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" }
kotlinx-coroutines-rx3 = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-rx3", version.ref = "coroutines" }
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" }
image-cropper = { module = "com.github.CanHub:Android-Image-Cropper", version.ref = "image-cropper" } image-cropper = { module = "com.github.CanHub:Android-Image-Cropper", version.ref = "image-cropper" }
material-drawer-core = { module = "com.mikepenz:materialdrawer", version.ref = "material-drawer" } material-drawer-core = { module = "com.mikepenz:materialdrawer", version.ref = "material-drawer" }
@ -133,13 +126,9 @@ mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "
networkresult-calladapter = { module = "at.connyduck:networkresult-calladapter", version.ref = "networkresult-calladapter" } networkresult-calladapter = { module = "at.connyduck:networkresult-calladapter", version.ref = "networkresult-calladapter" }
okhttp-core = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } okhttp-core = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
okhttp-logging-interceptor = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "okhttp" } okhttp-logging-interceptor = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "okhttp" }
retrofit-adapter-rxjava3 = { module = "com.squareup.retrofit2:adapter-rxjava3", version.ref = "retrofit" }
retrofit-converter-gson = { module = "com.squareup.retrofit2:converter-gson", version.ref = "retrofit" } retrofit-converter-gson = { module = "com.squareup.retrofit2:converter-gson", version.ref = "retrofit" }
retrofit-core = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" } retrofit-core = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" }
robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" }
rxjava3-android = { module = "io.reactivex.rxjava3:rxandroid", version.ref = "rxandroid3" }
rxjava3-core = { module = "io.reactivex.rxjava3:rxjava", version.ref = "rxjava3" }
rxjava3-kotlin = { module = "io.reactivex.rxjava3:rxkotlin", version.ref = "rxkotlin3" }
sparkbutton = { module = "at.connyduck.sparkbutton:sparkbutton", version.ref = "sparkbutton" } sparkbutton = { module = "at.connyduck.sparkbutton:sparkbutton", version.ref = "sparkbutton" }
touchimageview = { module = "com.github.MikeOrtiz:TouchImageView", version.ref = "touchimageview" } touchimageview = { module = "com.github.MikeOrtiz:TouchImageView", version.ref = "touchimageview" }
truth = { module = "com.google.truth:truth", version.ref = "truth" } truth = { module = "com.google.truth:truth", version.ref = "truth" }
@ -155,7 +144,6 @@ androidx = ["androidx-core-ktx", "androidx-appcompat", "androidx-fragment-ktx",
"androidx-constraintlayout", "androidx-paging-runtime-ktx", "androidx-viewpager2", "androidx-work-runtime-ktx", "androidx-constraintlayout", "androidx-paging-runtime-ktx", "androidx-viewpager2", "androidx-work-runtime-ktx",
"androidx-core-splashscreen", "androidx-activity", "androidx-media3-exoplayer", "androidx-media3-exoplayer-dash", "androidx-core-splashscreen", "androidx-activity", "androidx-media3-exoplayer", "androidx-media3-exoplayer-dash",
"androidx-media3-exoplayer-hls", "androidx-media3-exoplayer-rtsp", "androidx-media3-datasource-okhttp", "androidx-media3-ui"] "androidx-media3-exoplayer-hls", "androidx-media3-exoplayer-rtsp", "androidx-media3-datasource-okhttp", "androidx-media3-ui"]
autodispose = ["autodispose-core", "autodispose-android-lifecycle"]
dagger = ["dagger-core", "dagger-android-core", "dagger-android-support"] dagger = ["dagger-core", "dagger-android-core", "dagger-android-support"]
dagger-processors = ["dagger-compiler", "dagger-android-processor"] dagger-processors = ["dagger-compiler", "dagger-android-processor"]
filemojicompat = ["filemojicompat-core", "filemojicompat-ui", "filemojicompat-defaults"] filemojicompat = ["filemojicompat-core", "filemojicompat-ui", "filemojicompat-defaults"]
@ -163,7 +151,6 @@ glide = ["glide-core", "glide-okhttp3-integration", "glide-animation-plugin"]
material-drawer = ["material-drawer-core", "material-drawer-iconics"] material-drawer = ["material-drawer-core", "material-drawer-iconics"]
mockito = ["mockito-kotlin", "mockito-inline"] mockito = ["mockito-kotlin", "mockito-inline"]
okhttp = ["okhttp-core", "okhttp-logging-interceptor"] okhttp = ["okhttp-core", "okhttp-logging-interceptor"]
retrofit = ["retrofit-core", "retrofit-converter-gson", "retrofit-adapter-rxjava3"] retrofit = ["retrofit-core", "retrofit-converter-gson"]
room = ["androidx-room-ktx", "androidx-room-paging"] room = ["androidx-room-ktx", "androidx-room-paging"]
rxjava3 = ["rxjava3-core", "rxjava3-android", "rxjava3-kotlin"]
xmldiff = ["diffx", "xmlwriter"] xmldiff = ["diffx", "xmlwriter"]