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
dependencies {
implementation libs.kotlinx.coroutines.android
implementation libs.kotlinx.coroutines.rx3
implementation libs.bundles.androidx
implementation libs.bundles.room
@ -147,10 +146,6 @@ dependencies {
implementation libs.bundles.glide
ksp libs.glide.compiler
implementation libs.bundles.rxjava3
implementation libs.bundles.autodispose
implementation libs.bundles.dagger
kapt libs.bundles.dagger.processors

View File

@ -68,7 +68,6 @@
-keep,allowobfuscation,allowshrinking class * extends com.google.gson.reflect.TypeToken
# 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 kotlin.collections.List
-keep,allowobfuscation,allowshrinking class kotlin.collections.Map

View File

@ -22,9 +22,8 @@ import android.view.View
import android.widget.LinearLayout
import android.widget.Toast
import androidx.annotation.VisibleForTesting
import androidx.lifecycle.Lifecycle
import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider
import autodispose2.autoDispose
import androidx.lifecycle.lifecycleScope
import at.connyduck.calladapter.networkresult.fold
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.keylesspalace.tusky.components.account.AccountActivity
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.openLink
import com.keylesspalace.tusky.util.startActivityWithSlideInAnimation
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import javax.inject.Inject
import kotlinx.coroutines.launch
/** 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
@ -74,39 +73,39 @@ abstract class BottomSheetActivity : BaseActivity() {
return
}
mastodonApi.searchObservable(
query = url,
resolve = true
).observeOn(AndroidSchedulers.mainThread())
.autoDispose(AndroidLifecycleScopeProvider.from(this, Lifecycle.Event.ON_DESTROY))
.subscribe(
{ (accounts, statuses) ->
lifecycleScope.launch {
mastodonApi.search(
query = url,
resolve = true
).fold(
onSuccess = { (accounts, statuses) ->
if (getCancelSearchRequested(url)) {
return@subscribe
return@launch
}
onEndSearch(url)
if (statuses.isNotEmpty()) {
viewThread(statuses[0].id, statuses[0].url)
return@subscribe
return@launch
}
accounts.firstOrNull { it.url.equals(url, ignoreCase = true) }?.let { account ->
// Some servers return (unrelated) accounts for url searches (#2804)
// Verify that the account's url matches the query
viewAccount(account.id)
return@subscribe
return@launch
}
performUrlFallbackAction(url, lookupFallbackBehavior)
},
{
onFailure = {
if (!getCancelSearchRequested(url)) {
onEndSearch(url)
performUrlFallbackAction(url, lookupFallbackBehavior)
}
}
)
}
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.db.AccountEntity
import com.keylesspalace.tusky.db.DraftsAlert
import com.keylesspalace.tusky.di.ApplicationScope
import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.entity.Notification
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.usecase.DeveloperToolsUseCase
import com.keylesspalace.tusky.usecase.LogoutUsecase
import com.keylesspalace.tusky.util.ShareShortcutHelper
import com.keylesspalace.tusky.util.deleteStaleCachedMedia
import com.keylesspalace.tusky.util.emojify
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.supportsOverridingActivityTransitions
import com.keylesspalace.tusky.util.unsafeLazy
import com.keylesspalace.tusky.util.updateShortcut
import com.keylesspalace.tusky.util.viewBinding
import com.mikepenz.iconics.IconicsDrawable
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.HasAndroidInjector
import de.c1710.filemojicompat_ui.helpers.EMOJI_PREFERENCE
import io.reactivex.rxjava3.schedulers.Schedulers
import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@ -167,6 +168,13 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
@Inject
lateinit var developerToolsUseCase: DeveloperToolsUseCase
@Inject
lateinit var shareShortcutHelper: ShareShortcutHelper
@Inject
@ApplicationScope
lateinit var externalScope: CoroutineScope
private val binding by viewBinding(ActivityMainBinding::inflate)
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
deleteStaleCachedMedia(applicationContext.getExternalFilesDir("Tusky"))
}
@ -1056,7 +1064,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
}
updateProfiles()
updateShortcut(this, accountManager.activeAccount!!)
shareShortcutHelper.updateShortcut(accountManager.activeAccount!!)
}
@SuppressLint("CheckResult")

View File

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

View File

@ -42,13 +42,10 @@ import androidx.core.app.ShareCompat
import androidx.core.content.FileProvider
import androidx.core.content.IntentCompat
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.viewpager2.adapter.FragmentStateAdapter
import androidx.viewpager2.widget.ViewPager2
import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider
import autodispose2.autoDispose
import com.bumptech.glide.Glide
import com.bumptech.glide.request.FutureTarget
import com.keylesspalace.tusky.BuildConfig.APPLICATION_ID
import com.keylesspalace.tusky.components.viewthread.ViewThreadActivity
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.util.getTemporaryMediaFilename
import com.keylesspalace.tusky.util.startActivityWithSlideInAnimation
import com.keylesspalace.tusky.util.submitAsync
import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.viewdata.AttachmentViewData
import dagger.android.DispatchingAndroidInjector
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.FileNotFoundException
import java.io.FileOutputStream
import java.io.IOException
import java.util.Locale
import javax.inject.Inject
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.launch
typealias ToolbarVisibilityListener = (isVisible: Boolean) -> Unit
@ -310,46 +306,37 @@ class ViewMediaActivity :
isCreating = true
binding.progressBarShare.visibility = View.VISIBLE
invalidateOptionsMenu()
val file = File(directory, getTemporaryMediaFilename("png"))
val futureTask: FutureTarget<Bitmap> =
Glide.with(applicationContext).asBitmap().load(Uri.parse(url)).submit()
Single.fromCallable {
val bitmap = futureTask.get()
try {
val stream = FileOutputStream(file)
bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream)
stream.close()
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")
lifecycleScope.launch {
val file = File(directory, getTemporaryMediaFilename("png"))
val result = try {
val bitmap =
Glide.with(applicationContext).asBitmap().load(Uri.parse(url)).submitAsync()
try {
FileOutputStream(file).use { stream ->
bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream)
}
},
{ error ->
isCreating = false
invalidateOptionsMenu()
binding.progressBarShare.visibility = View.GONE
Log.e(TAG, "Failed to download image", error)
true
} catch (ioe: IOException) {
// FileNotFoundException is covered by IOException
Log.e(TAG, "Error writing temporary media.")
false
}.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) {

View File

@ -1,30 +1,33 @@
package com.keylesspalace.tusky.appstore
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.subjects.PublishSubject
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import java.util.function.Consumer
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.launch
interface Event
@Singleton
class EventHub @Inject constructor() {
private val sharedEventFlow: MutableSharedFlow<Event> = MutableSharedFlow()
val events: Flow<Event> = sharedEventFlow
// TODO remove this old stuff as soon as NotificationsFragment is Kotlin
private val eventsSubject = PublishSubject.create<Event>()
val eventsObservable: Observable<Event> = eventsSubject
private val sharedEventFlow = MutableSharedFlow<Event>()
val events: SharedFlow<Event> = sharedEventFlow.asSharedFlow()
suspend fun dispatch(event: Event) {
sharedEventFlow.emit(event)
eventsSubject.onNext(event)
}
fun dispatchOld(event: Event) {
eventsSubject.onNext(event)
// TODO remove as soon as NotificationsFragment is Kotlin
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.view.View
import androidx.fragment.app.Fragment
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.ConcatAdapter
@ -28,8 +27,6 @@ import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.SimpleItemAnimator
import at.connyduck.calladapter.networkresult.fold
import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider.from
import autodispose2.autoDispose
import com.google.android.material.snackbar.Snackbar
import com.keylesspalace.tusky.BottomSheetActivity
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.viewBinding
import com.keylesspalace.tusky.view.EndlessOnScrollListener
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import javax.inject.Inject
import kotlinx.coroutines.launch
import retrofit2.Response
@ -249,17 +245,16 @@ class AccountListFragment :
}
override fun onRespondToFollowRequest(accept: Boolean, accountId: String, position: Int) {
if (accept) {
api.authorizeFollowRequest(accountId)
} else {
api.rejectFollowRequest(accountId)
}.observeOn(AndroidSchedulers.mainThread())
.autoDispose(from(this, Lifecycle.Event.ON_DESTROY))
.subscribe(
{
viewLifecycleOwner.lifecycleScope.launch {
if (accept) {
api.authorizeFollowRequest(accountId)
} else {
api.rejectFollowRequest(accountId)
}.fold(
onSuccess = {
onRespondToFollowRequestSuccess(position)
},
{ throwable ->
onFailure = { throwable ->
val verb = if (accept) {
"accept"
} else {
@ -268,6 +263,7 @@ class AccountListFragment :
Log.e(TAG, "Failed to $verb account id $accountId.", throwable)
}
)
}
}
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.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
class ComposeViewModel @Inject constructor(
@ -412,9 +413,9 @@ class ComposeViewModel @Inject constructor(
}
fun searchAutocompleteSuggestions(token: String): List<AutocompleteResult> {
when (token[0]) {
'@' -> {
return api.searchAccountsSync(query = token.substring(1), limit = 10)
return when (token[0]) {
'@' -> runBlocking {
api.searchAccounts(query = token.substring(1), limit = 10)
.fold({ accounts ->
accounts.map { AutocompleteResult.AccountResult(it) }
}, { e ->
@ -422,8 +423,8 @@ class ComposeViewModel @Inject constructor(
emptyList()
})
}
'#' -> {
return api.searchSync(
'#' -> runBlocking {
api.search(
query = token,
type = SearchType.Hashtag.apiParameter,
limit = 10
@ -439,7 +440,7 @@ class ComposeViewModel @Inject constructor(
val emojiList = emoji.replayCache.firstOrNull() ?: return emptyList()
val incomplete = token.substring(1)
return emojiList.filter { emoji ->
emojiList.filter { emoji ->
emoji.shortcode.contains(incomplete, ignoreCase = true)
}.sortedBy { emoji ->
emoji.shortcode.indexOf(incomplete, ignoreCase = true)
@ -449,7 +450,7 @@ class ComposeViewModel @Inject constructor(
}
else -> {
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.network.MastodonApi
import javax.inject.Inject
import kotlinx.coroutines.runBlocking
class FollowedTagsViewModel @Inject constructor(
private val api: MastodonApi
@ -38,17 +39,19 @@ class FollowedTagsViewModel @Inject constructor(
fun searchAutocompleteSuggestions(
token: String
): List<ComposeAutoCompleteAdapter.AutocompleteResult> {
return api.searchSync(query = token, type = SearchType.Hashtag.apiParameter, limit = 10)
.fold({ searchResult ->
searchResult.hashtags.map {
ComposeAutoCompleteAdapter.AutocompleteResult.HashtagResult(
it.name
)
}
}, { e ->
Log.e(TAG, "Autocomplete search for $token failed.", e)
emptyList()
})
return runBlocking {
api.search(query = token, type = SearchType.Hashtag.apiParameter, limit = 10)
.fold({ searchResult ->
searchResult.hashtags.map {
ComposeAutoCompleteAdapter.AutocompleteResult.HashtagResult(
it.name
)
}
}, { e ->
Log.e(TAG, "Autocomplete search for $token failed.", e)
emptyList()
})
}
}
companion object {

View File

@ -18,12 +18,11 @@ package com.keylesspalace.tusky.components.report.adapter
import android.util.Log
import androidx.paging.PagingSource
import androidx.paging.PagingState
import at.connyduck.calladapter.networkresult.getOrThrow
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.network.MastodonApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.rx3.await
import kotlinx.coroutines.withContext
import kotlinx.coroutines.coroutineScope
class StatusesPagingSource(
private val accountId: String,
@ -40,7 +39,9 @@ class StatusesPagingSource(
val key = params.key
try {
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 additionalStatuses =
async { getStatusList(maxId = key, limit = params.loadSize - 1) }
@ -73,7 +74,7 @@ class StatusesPagingSource(
}
private suspend fun getSingleStatus(statusId: String): Status {
return mastodonApi.statusObservable(statusId).await()
return mastodonApi.status(statusId).getOrThrow()
}
private suspend fun getStatusList(
@ -81,13 +82,13 @@ class StatusesPagingSource(
maxId: String? = null,
limit: Int
): List<Status> {
return mastodonApi.accountStatusesObservable(
return mastodonApi.accountStatuses(
accountId = accountId,
maxId = maxId,
sinceId = null,
minId = minId,
limit = limit,
excludeReblogs = true
).await()
).getOrThrow()
}
}

View File

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

View File

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

View File

@ -37,7 +37,6 @@ import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.SimpleItemAnimator
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener
import at.connyduck.sparkbutton.helpers.Utils
import autodispose2.androidx.lifecycle.autoDispose
import com.google.android.material.color.MaterialColors
import com.keylesspalace.tusky.R
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.startActivityWithSlideInAnimation
import com.keylesspalace.tusky.util.unsafeLazy
import com.keylesspalace.tusky.util.updateRelativeTimePeriodically
import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.viewdata.AttachmentViewData
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.utils.colorInt
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 kotlinx.coroutines.flow.collectLatest
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) {
@ -598,29 +603,6 @@ class TimelineFragment :
if (talkBackWasEnabled && !wasEnabled) {
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() {

View File

@ -20,6 +20,7 @@ package com.keylesspalace.tusky.di
import dagger.Module
import dagger.Provides
import javax.inject.Qualifier
import javax.inject.Singleton
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@ -40,5 +41,6 @@ annotation class ApplicationScope
class CoroutineScopeModule {
@ApplicationScope
@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.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.adapter.rxjava3.RxJava3CallAdapterFactory
import retrofit2.converter.gson.GsonConverterFactory
import retrofit2.create
@ -118,7 +117,6 @@ class NetworkModule {
return Retrofit.Builder().baseUrl("https://" + MastodonApi.PLACEHOLDER_DOMAIN)
.client(httpClient)
.addConverterFactory(GsonConverterFactory.create(gson))
.addCallAdapterFactory(RxJava3CallAdapterFactory.create())
.addCallAdapterFactory(NetworkResultCallAdapterFactory.create())
.build()
}

View File

@ -16,8 +16,6 @@
package com.keylesspalace.tusky.fragment;
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.content.Context;
@ -86,6 +84,8 @@ import com.keylesspalace.tusky.util.ListStatusAccessibilityDelegate;
import com.keylesspalace.tusky.util.ListUtils;
import com.keylesspalace.tusky.util.NotificationTypeConverterKt;
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.ViewDataUtils;
import com.keylesspalace.tusky.view.EndlessOnScrollListener;
@ -102,19 +102,14 @@ import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import javax.inject.Inject;
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.collections.CollectionsKt;
import kotlin.jvm.functions.Function1;
import kotlinx.coroutines.Job;
public class NotificationsFragment extends SFragment implements
SwipeRefreshLayout.OnRefreshListener,
@ -131,7 +126,7 @@ public class NotificationsFragment extends SFragment implements
private final Set<Notification.Type> notificationFilter = new HashSet<>();
private final CompositeDisposable disposables = new CompositeDisposable();
private final ArrayList<Job> jobs = new ArrayList<>();
private enum FetchEnd {
TOP,
@ -382,10 +377,9 @@ public class NotificationsFragment extends SFragment implements
binding.recyclerView.addOnScrollListener(scrollListener);
eventHub.getEventsObservable()
.observeOn(AndroidSchedulers.mainThread())
.to(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY)))
.subscribe(event -> {
eventHub.subscribe(
getViewLifecycleOwner(),
event -> {
if (event instanceof StatusChangedEvent) {
Status updatedStatus = ((StatusChangedEvent) event).getStatus();
updateStatus(updatedStatus.getActionableId(), s -> updatedStatus);
@ -394,7 +388,10 @@ public class NotificationsFragment extends SFragment implements
} else if (event instanceof PreferenceChangedEvent) {
onPreferenceChanged(((PreferenceChangedEvent) event).getPreferenceKey());
}
});
}
);
RelativeTimeUpdater.updateRelativeTimePeriodically(this, this::updateAdapter);
}
@Override
@ -422,13 +419,12 @@ public class NotificationsFragment extends SFragment implements
final Status status = notification.getStatus();
Objects.requireNonNull(status, "Reblog on notification without status");
timelineCases.reblogOld(status.getId(), reblog)
.observeOn(AndroidSchedulers.mainThread())
.to(autoDisposable(from(this)))
.subscribe(
(newStatus) -> setReblogForStatus(status.getId(), reblog),
(t) -> Log.d(getClass().getSimpleName(),
"Failed to reblog status: " + status.getId(), t)
);
.subscribe(
getViewLifecycleOwner(),
(newStatus) -> setReblogForStatus(status.getId(), reblog),
(t) -> Log.d(getClass().getSimpleName(),
"Failed to reblog status: " + status.getId(), t)
);
}
private void setReblogForStatus(String statusId, boolean reblog) {
@ -441,13 +437,12 @@ public class NotificationsFragment extends SFragment implements
final Status status = notification.getStatus();
timelineCases.favouriteOld(status.getId(), favourite)
.observeOn(AndroidSchedulers.mainThread())
.to(autoDisposable(from(this)))
.subscribe(
(newStatus) -> setFavouriteForStatus(status.getId(), favourite),
(t) -> Log.d(getClass().getSimpleName(),
"Failed to favourite status: " + status.getId(), t)
);
.subscribe(
getViewLifecycleOwner(),
(newStatus) -> setFavouriteForStatus(status.getId(), favourite),
(t) -> Log.d(getClass().getSimpleName(),
"Failed to favourite status: " + status.getId(), t)
);
}
private void setFavouriteForStatus(String statusId, boolean favourite) {
@ -460,13 +455,12 @@ public class NotificationsFragment extends SFragment implements
final Status status = notification.getStatus();
timelineCases.bookmarkOld(status.getActionableId(), bookmark)
.observeOn(AndroidSchedulers.mainThread())
.to(autoDisposable(from(this)))
.subscribe(
(newStatus) -> setBookmarkForStatus(status.getId(), bookmark),
(t) -> Log.d(getClass().getSimpleName(),
"Failed to bookmark status: " + status.getId(), t)
);
.subscribe(
getViewLifecycleOwner(),
(newStatus) -> setBookmarkForStatus(status.getId(), bookmark),
(t) -> Log.d(getClass().getSimpleName(),
"Failed to bookmark status: " + status.getId(), t)
);
}
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 Status status = notification.getStatus().getActionableStatus();
timelineCases.voteInPollOld(status.getId(), status.getPoll().getId(), choices)
.observeOn(AndroidSchedulers.mainThread())
.to(autoDisposable(from(this)))
.subscribe(
(newPoll) -> setVoteForPoll(status, newPoll),
(t) -> Log.d(TAG,
"Failed to vote in poll: " + status.getId(), t)
);
.subscribe(
getViewLifecycleOwner(),
(newPoll) -> setVoteForPoll(status, newPoll),
(t) -> Log.d(TAG, "Failed to vote in poll: " + status.getId(), t)
);
}
@Override
@ -648,21 +640,23 @@ public class NotificationsFragment extends SFragment implements
updateAdapter();
// Execute clear notifications request
mastodonApi.clearNotificationsOld()
.observeOn(AndroidSchedulers.mainThread())
.to(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY)))
.subscribe(
response -> {
// Nothing to do
},
throwable -> {
// Reload notifications on failure
fullyRefreshWithProgressBar(true);
});
timelineCases.clearNotificationsOld()
.subscribe(
getViewLifecycleOwner(),
response -> {
// Nothing to do
},
throwable -> {
// Reload notifications on failure
fullyRefreshWithProgressBar(true);
});
}
private void resetNotificationsLoad() {
disposables.clear();
for (Job job : jobs) {
job.cancel(null);
}
jobs.clear();
bottomLoading = false;
topLoading = false;
@ -797,15 +791,14 @@ public class NotificationsFragment extends SFragment implements
@Override
public void onRespondToFollowRequest(boolean accept, String id, int position) {
Single<Relationship> request = accept ?
mastodonApi.authorizeFollowRequest(id) :
mastodonApi.rejectFollowRequest(id);
request.observeOn(AndroidSchedulers.mainThread())
.to(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY)))
.subscribe(
(relationship) -> fullyRefreshWithProgressBar(true),
(error) -> Log.e(TAG, String.format("Failed to %s account id %s", accept ? "accept" : "reject", id))
);
final Single<Relationship> request = accept ?
timelineCases.acceptFollowRequestOld(id) :
timelineCases.rejectFollowRequestOld(id);
request.subscribe(
getViewLifecycleOwner(),
(relationship) -> fullyRefreshWithProgressBar(true),
(error) -> Log.e(TAG, String.format("Failed to %s account id %s", accept ? "accept" : "reject", id))
);
}
@Override
@ -927,20 +920,20 @@ public class NotificationsFragment extends SFragment implements
bottomLoading = true;
}
Disposable notificationCall = mastodonApi.notificationsOld(fromId, uptoId, LOAD_AT_ONCE, showNotificationsFilter ? notificationFilter : null)
.observeOn(AndroidSchedulers.mainThread())
.to(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY)))
.subscribe(
response -> {
if (response.isSuccessful()) {
String linkHeader = response.headers().get("Link");
onFetchNotificationsSuccess(response.body(), linkHeader, fetchEnd, pos);
} else {
onFetchNotificationsFailure(new Exception(response.message()), fetchEnd, pos);
}
},
throwable -> onFetchNotificationsFailure(throwable, fetchEnd, pos));
disposables.add(notificationCall);
Job notificationCall = timelineCases.notificationsOld(fromId, uptoId, LOAD_AT_ONCE, showNotificationsFilter ? notificationFilter : null)
.subscribe(
getViewLifecycleOwner(),
response -> {
if (response.isSuccessful()) {
String linkHeader = response.headers().get("Link");
onFetchNotificationsSuccess(response.body(), linkHeader, fetchEnd, pos);
} else {
onFetchNotificationsFailure(new Exception(response.message()), fetchEnd, pos);
}
},
throwable -> onFetchNotificationsFailure(throwable, fetchEnd, pos)
);
jobs.add(notificationCall);
}
private void onFetchNotificationsSuccess(List<Notification> notifications, String linkHeader,
@ -1250,26 +1243,6 @@ public class NotificationsFragment extends SFragment implements
loadNotificationsFilter();
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

View File

@ -30,6 +30,7 @@ import android.view.ViewGroup
import android.widget.ImageView
import androidx.core.os.BundleCompat
import androidx.core.view.GestureDetectorCompat
import androidx.lifecycle.lifecycleScope
import com.bumptech.glide.Glide
import com.bumptech.glide.load.DataSource
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.ortiz.touchview.OnTouchCoordinatesListener
import com.ortiz.touchview.TouchImageView
import io.reactivex.rxjava3.subjects.BehaviorSubject
import kotlin.math.abs
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.launch
class ViewImageFragment : ViewMediaFragment() {
interface PhotoActionsListener {
@ -58,7 +60,7 @@ class ViewImageFragment : ViewMediaFragment() {
private lateinit var photoActionsListener: PhotoActionsListener
private lateinit var toolbar: View
private var transition = BehaviorSubject.create<Unit>()
private var transition: CompletableDeferred<Unit>? = null
private var shouldStartTransition = false
// Volatile: Image requests happen on background thread and we want to see updates to it
@ -91,7 +93,7 @@ class ViewImageFragment : ViewMediaFragment() {
savedInstanceState: Bundle?
): View {
toolbar = (requireActivity() as ViewMediaActivity).toolbar
this.transition = BehaviorSubject.create()
this.transition = CompletableDeferred()
return inflater.inflate(R.layout.fragment_view_image, container, false)
}
@ -246,7 +248,7 @@ class ViewImageFragment : ViewMediaFragment() {
}
override fun onDestroyView() {
transition.onComplete()
transition = null
super.onDestroyView()
}
@ -345,19 +347,20 @@ class ViewImageFragment : ViewMediaFragment() {
if (shouldStartTransition) photoActionsListener.onBringUp()
}
} else {
// This wait for transition. If there's no transition then we should hit
// another branch. take() will unsubscribe after we have it to not leak memory
transition
.take(1)
.subscribe {
// This waits for transition. If there's no transition then we should hit
// another branch. When the view is destroyed the coroutine is automatically canceled.
transition?.let {
viewLifecycleOwner.lifecycleScope.launch {
it.await()
target.onResourceReady(resource, null)
}
}
}
return true
}
}
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.TimelineAccount
import com.keylesspalace.tusky.entity.TrendingTag
import io.reactivex.rxjava3.core.Single
import okhttp3.MultipartBody
import okhttp3.RequestBody
import okhttp3.ResponseBody
@ -139,22 +138,14 @@ interface MastodonApi {
@GET("api/v1/notifications")
suspend fun notifications(
/** 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?,
/** Return results newer than this ID */
@Query("since_id") sinceId: String?,
/** Maximum number of results to return. Defaults to 15, max is 30 */
@Query("limit") limit: Int?,
/** Types to excludes from the results */
@Query("exclude_types[]") excludes: Set<Notification.Type>?
): Single<Response<List<Notification>>>
): Response<List<Notification>>
/** Fetch a single notification */
@GET("api/v1/notifications/{id}")
@ -185,10 +176,7 @@ interface MastodonApi {
): Response<List<Notification>>
@POST("api/v1/notifications/clear")
suspend fun clearNotifications(): Response<ResponseBody>
@POST("api/v1/notifications/clear")
fun clearNotificationsOld(): Single<ResponseBody>
suspend fun clearNotifications(): NetworkResult<ResponseBody>
@FormUrlEncoded
@PUT("api/v1/media/{mediaId}")
@ -221,9 +209,6 @@ interface MastodonApi {
@Body editedStatus: NewStatus
): NetworkResult<Status>
@GET("api/v1/statuses/{id}")
suspend fun statusAsync(@Path("id") statusId: String): NetworkResult<Status>
@GET("api/v1/statuses/{id}/source")
suspend fun statusSource(@Path("id") statusId: String): NetworkResult<StatusSource>
@ -266,24 +251,6 @@ interface MastodonApi {
@POST("api/v1/statuses/{id}/unbookmark")
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")
suspend fun pinStatus(@Path("id") statusId: String): NetworkResult<Status>
@ -296,17 +263,11 @@ interface MastodonApi {
@POST("api/v1/statuses/{id}/unmute")
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")
fun scheduledStatuses(
suspend fun scheduledStatuses(
@Query("limit") limit: Int? = null,
@Query("max_id") maxId: String? = null
): Single<List<ScheduledStatus>>
): NetworkResult<List<ScheduledStatus>>
@DELETE("api/v1/scheduled_statuses/{id}")
suspend fun deleteScheduledStatus(
@ -353,14 +314,6 @@ interface MastodonApi {
@Query("following") following: Boolean? = null
): 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}")
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>>
@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")
fun rejectFollowRequest(@Path("id") accountId: String): Single<Relationship>
suspend fun rejectFollowRequest(@Path("id") accountId: String): NetworkResult<Relationship>
@FormUrlEncoded
@POST("api/v1/apps")
@ -641,10 +594,6 @@ interface MastodonApi {
@Field("choices[]") choices: List<Int>
): 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")
suspend fun listAnnouncements(
@Query("with_dismissed") withDismissed: Boolean = true
@ -675,30 +624,17 @@ interface MastodonApi {
): NetworkResult<Unit>
@GET("api/v1/accounts/{id}/statuses")
fun accountStatusesObservable(
suspend fun accountStatuses(
@Path("id") accountId: String,
@Query("max_id") maxId: String?,
@Query("since_id") sinceId: String?,
@Query("min_id") minId: String?,
@Query("limit") limit: Int?,
@Query("exclude_reblogs") excludeReblogs: Boolean?
): Single<List<Status>>
@GET("api/v1/statuses/{id}")
fun statusObservable(@Path("id") statusId: String): Single<Status>
): NetworkResult<List<Status>>
@GET("api/v2/search")
fun searchObservable(
@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(
suspend fun search(
@Query("q") query: String?,
@Query("type") type: String? = 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.updateUnifiedPushSubscription
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.di.ApplicationScope
import com.keylesspalace.tusky.network.MastodonApi
import dagger.android.AndroidInjection
import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
@DelicateCoroutinesApi
@ -39,6 +40,10 @@ class NotificationBlockStateBroadcastReceiver : BroadcastReceiver() {
@Inject
lateinit var accountManager: AccountManager
@Inject
@ApplicationScope
lateinit var externalScope: CoroutineScope
override fun onReceive(context: Context, intent: Intent) {
AndroidInjection.inject(this, context)
if (Build.VERSION.SDK_INT < 28) return
@ -60,7 +65,7 @@ class NotificationBlockStateBroadcastReceiver : BroadcastReceiver() {
accountManager.getAccountByIdentifier(gid)?.let { account ->
if (isUnifiedPushNotificationEnabledForAccount(account)) {
// Update UnifiedPush notification subscription
GlobalScope.launch {
externalScope.launch {
updateUnifiedPushSubscription(
context,
mastodonApi,

View File

@ -23,12 +23,13 @@ import androidx.work.WorkManager
import com.keylesspalace.tusky.components.notifications.registerUnifiedPushEndpoint
import com.keylesspalace.tusky.components.notifications.unregisterUnifiedPushEndpoint
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.di.ApplicationScope
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.worker.NotificationWorker
import dagger.android.AndroidInjection
import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import org.unifiedpush.android.connector.MessagingReceiver
@ -44,6 +45,10 @@ class UnifiedPushBroadcastReceiver : MessagingReceiver() {
@Inject
lateinit var mastodonApi: MastodonApi
@Inject
@ApplicationScope
lateinit var externalScope: CoroutineScope
override fun onReceive(context: Context, intent: Intent) {
super.onReceive(context, intent)
AndroidInjection.inject(this, context)
@ -61,9 +66,7 @@ class UnifiedPushBroadcastReceiver : MessagingReceiver() {
AndroidInjection.inject(this, context)
Log.d(TAG, "Endpoint available for account $instance: $endpoint")
accountManager.getAccountById(instance.toLong())?.let {
// Launch the coroutine in global scope -- it is short and we don't want to lose the registration event
// and there is no saner way to use structured concurrency in a receiver
GlobalScope.launch {
externalScope.launch {
registerUnifiedPushEndpoint(context, mastodonApi, accountManager, it, endpoint)
}
}
@ -76,7 +79,7 @@ class UnifiedPushBroadcastReceiver : MessagingReceiver() {
Log.d(TAG, "Endpoint unregistered for account $instance")
accountManager.getAccountById(instance.toLong())?.let {
// 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.AppDatabase
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.removeShortcut
import com.keylesspalace.tusky.util.ShareShortcutHelper
import javax.inject.Inject
class LogoutUsecase @Inject constructor(
@ -15,7 +15,8 @@ class LogoutUsecase @Inject constructor(
private val api: MastodonApi,
private val db: AppDatabase,
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)
// remove shortcut associated with the account
removeShortcut(context, activeAccount)
shareShortcutHelper.removeShortcut(activeAccount)
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.onFailure
import at.connyduck.calladapter.networkresult.onSuccess
import at.connyduck.calladapter.networkresult.runCatching
import com.keylesspalace.tusky.appstore.BlockEvent
import com.keylesspalace.tusky.appstore.EventHub
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.StatusDeletedEvent
import com.keylesspalace.tusky.entity.DeletedStatus
import com.keylesspalace.tusky.entity.Notification
import com.keylesspalace.tusky.entity.Poll
import com.keylesspalace.tusky.entity.Relationship
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.Single
import com.keylesspalace.tusky.util.getServerErrorMessage
import io.reactivex.rxjava3.core.Single
import javax.inject.Inject
import okhttp3.ResponseBody
import retrofit2.Response
/**
* 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> {
return if (favourite) {
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> {
return if (bookmark) {
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> {
return if (mute) {
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?) {
try {
mastodonApi.muteAccount(statusId, notifications, duration)
@ -188,21 +160,28 @@ class TimelineCases @Inject constructor(
}
fun voteInPollOld(statusId: String, pollId: String, choices: List<Int>): Single<Poll> {
if (choices.isEmpty()) {
return Single.error(IllegalStateException())
}
return mastodonApi.voteInPollOld(pollId, choices).doAfterSuccess {
eventHub.dispatchOld(PollVoteEvent(statusId, it))
}
return Single { voteInPoll(statusId, pollId, choices) }
}
fun acceptFollowRequest(accountId: String): Single<Relationship> {
return mastodonApi.authorizeFollowRequest(accountId)
fun acceptFollowRequestOld(accountId: String): Single<Relationship> {
return Single { mastodonApi.authorizeFollowRequest(accountId) }
}
fun rejectFollowRequest(accountId: String): Single<Relationship> {
return mastodonApi.rejectFollowRequest(accountId)
fun rejectFollowRequestOld(accountId: String): Single<Relationship> {
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 {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -124,26 +124,6 @@
license:link="https://google.github.io/dagger/"
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
android:layout_width="match_parent"
android:layout_height="wrap_content"

View File

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

View File

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

View File

@ -21,7 +21,6 @@ androidx-testing = "2.2.0"
androidx-viewpager2 = "1.0.0"
androidx-work = "2.9.0"
androidx-room = "2.6.1"
autodispose = "2.2.1"
bouncycastle = "1.70"
conscrypt = "2.5.2"
coroutines = "1.8.0"
@ -45,9 +44,6 @@ networkresult-calladapter = "1.1.0"
okhttp = "4.12.0"
retrofit = "2.9.0"
robolectric = "4.11.1"
rxandroid3 = "3.0.2"
rxjava3 = "3.1.8"
rxkotlin3 = "3.0.1"
sparkbutton = "4.2.0"
touchimageview = "3.6"
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-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" }
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" }
conscrypt-android = { module = "org.conscrypt:conscrypt-android", version.ref = "conscrypt" }
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" }
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-rx3 = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-rx3", 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" }
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" }
okhttp-core = { module = "com.squareup.okhttp3:okhttp", 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-core = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" }
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" }
touchimageview = { module = "com.github.MikeOrtiz:TouchImageView", version.ref = "touchimageview" }
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-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"]
autodispose = ["autodispose-core", "autodispose-android-lifecycle"]
dagger = ["dagger-core", "dagger-android-core", "dagger-android-support"]
dagger-processors = ["dagger-compiler", "dagger-android-processor"]
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"]
mockito = ["mockito-kotlin", "mockito-inline"]
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"]
rxjava3 = ["rxjava3-core", "rxjava3-android", "rxjava3-kotlin"]
xmldiff = ["diffx", "xmlwriter"]