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:
parent
91fe7a51cc
commit
40fde54e0b
32 changed files with 588 additions and 590 deletions
|
@ -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
|
||||
|
||||
|
|
1
app/proguard-rules.pro
vendored
1
app/proguard-rules.pro
vendored
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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) }
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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()))
|
||||
}
|
||||
}
|
||||
|
|
29
app/src/main/java/com/keylesspalace/tusky/util/Single.kt
Normal file
29
app/src/main/java/com/keylesspalace/tusky/util/Single.kt
Normal 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) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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())
|
||||
|
|
|
@ -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"]
|
||||
|
|
Loading…
Reference in a new issue