From 40fde54e0ba5f67ef7ce85bd672cf98fdc120997 Mon Sep 17 00:00:00 2001 From: Christophe Beyls Date: Thu, 29 Feb 2024 15:28:48 +0100 Subject: [PATCH] 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. --- app/build.gradle | 5 - app/proguard-rules.pro | 1 - .../tusky/BottomSheetActivity.kt | 29 ++- .../com/keylesspalace/tusky/MainActivity.kt | 16 +- .../keylesspalace/tusky/TuskyApplication.kt | 8 - .../keylesspalace/tusky/ViewMediaActivity.kt | 79 +++---- .../keylesspalace/tusky/appstore/EventsHub.kt | 27 +-- .../accountlist/AccountListFragment.kt | 22 +- .../components/compose/ComposeViewModel.kt | 15 +- .../followedtags/FollowedTagsViewModel.kt | 25 ++- .../report/adapter/StatusesPagingSource.kt | 15 +- .../scheduled/ScheduledStatusPagingSource.kt | 4 +- .../search/adapter/SearchPagingSource.kt | 6 +- .../components/timeline/TimelineFragment.kt | 36 +--- .../tusky/di/CoroutineScopeModule.kt | 4 +- .../keylesspalace/tusky/di/NetworkModule.kt | 2 - .../tusky/fragment/NotificationsFragment.java | 167 ++++++--------- .../tusky/fragment/ViewImageFragment.kt | 23 +- .../tusky/network/MastodonApi.kt | 88 ++------ ...NotificationBlockStateBroadcastReceiver.kt | 9 +- .../receiver/UnifiedPushBroadcastReceiver.kt | 13 +- .../tusky/usecase/LogoutUsecase.kt | 7 +- .../tusky/usecase/TimelineCases.kt | 91 +++----- .../tusky/util/GlideExtensions.kt | 47 ++++ .../keylesspalace/tusky/util/MediaUtils.kt | 8 +- .../tusky/util/RelativeTimeUpdater.kt | 37 ++++ .../tusky/util/ShareShortcutHelper.kt | 127 +++++------ .../com/keylesspalace/tusky/util/Single.kt | 29 +++ app/src/main/res/layout/activity_license.xml | 20 -- .../tusky/BottomSheetActivityTest.kt | 200 +++++++++++------- .../keylesspalace/tusky/MainActivityTest.kt | 3 + gradle/libs.versions.toml | 15 +- 32 files changed, 588 insertions(+), 590 deletions(-) create mode 100644 app/src/main/java/com/keylesspalace/tusky/util/GlideExtensions.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/util/RelativeTimeUpdater.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/util/Single.kt diff --git a/app/build.gradle b/app/build.gradle index 319d23113..826074afd 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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 diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 9ab3dd83f..e010d9402 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -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 diff --git a/app/src/main/java/com/keylesspalace/tusky/BottomSheetActivity.kt b/app/src/main/java/com/keylesspalace/tusky/BottomSheetActivity.kt index 8d62ed6b6..73c87cf2e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/BottomSheetActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/BottomSheetActivity.kt @@ -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) } diff --git a/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt b/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt index 92691dfd6..fa37839c3 100644 --- a/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt @@ -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") diff --git a/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.kt b/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.kt index c24a9fcd9..0476903a8 100644 --- a/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.kt +++ b/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.kt @@ -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( diff --git a/app/src/main/java/com/keylesspalace/tusky/ViewMediaActivity.kt b/app/src/main/java/com/keylesspalace/tusky/ViewMediaActivity.kt index 736f5a915..18654ec5c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/ViewMediaActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/ViewMediaActivity.kt @@ -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 = - 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) { diff --git a/app/src/main/java/com/keylesspalace/tusky/appstore/EventsHub.kt b/app/src/main/java/com/keylesspalace/tusky/appstore/EventsHub.kt index 7be722f3d..05bd4542c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/appstore/EventsHub.kt +++ b/app/src/main/java/com/keylesspalace/tusky/appstore/EventsHub.kt @@ -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 = MutableSharedFlow() - val events: Flow = sharedEventFlow - - // TODO remove this old stuff as soon as NotificationsFragment is Kotlin - private val eventsSubject = PublishSubject.create() - val eventsObservable: Observable = eventsSubject + private val sharedEventFlow = MutableSharedFlow() + val events: SharedFlow = 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) { + lifecycleOwner.lifecycleScope.launch { + events.collect { event -> + consumer.accept(event) + } + } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountListFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountListFragment.kt index 66b226144..729933114 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountListFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountListFragment.kt @@ -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) { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeViewModel.kt index 74e840f78..3c64e43b7 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeViewModel.kt @@ -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 { - 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() } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/followedtags/FollowedTagsViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/followedtags/FollowedTagsViewModel.kt index 63170b59d..f3376c13b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/followedtags/FollowedTagsViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/followedtags/FollowedTagsViewModel.kt @@ -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 { - 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 { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusesPagingSource.kt b/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusesPagingSource.kt index 2d2be224a..9b9238ed5 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusesPagingSource.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusesPagingSource.kt @@ -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 { - return mastodonApi.accountStatusesObservable( + return mastodonApi.accountStatuses( accountId = accountId, maxId = maxId, sinceId = null, minId = minId, limit = limit, excludeReblogs = true - ).await() + ).getOrThrow() } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/scheduled/ScheduledStatusPagingSource.kt b/app/src/main/java/com/keylesspalace/tusky/components/scheduled/ScheduledStatusPagingSource.kt index c9af661e4..57535b810 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/scheduled/ScheduledStatusPagingSource.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/scheduled/ScheduledStatusPagingSource.kt @@ -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, diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchPagingSource.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchPagingSource.kt index 10339536a..d91f929af 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchPagingSource.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchPagingSource.kt @@ -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( private val mastodonApi: MastodonApi, @@ -54,14 +54,14 @@ class SearchPagingSource( 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) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt index 649b4010f..8650c3435 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt @@ -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() { diff --git a/app/src/main/java/com/keylesspalace/tusky/di/CoroutineScopeModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/CoroutineScopeModule.kt index 2d1a1892b..7eef6543a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/CoroutineScopeModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/CoroutineScopeModule.kt @@ -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) } diff --git a/app/src/main/java/com/keylesspalace/tusky/di/NetworkModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/NetworkModule.kt index fd7c9767e..3fdc80233 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/NetworkModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/NetworkModule.kt @@ -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() } diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java index 7b67747fb..84a464f6e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java @@ -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 notificationFilter = new HashSet<>(); - private final CompositeDisposable disposables = new CompositeDisposable(); + private final ArrayList 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 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 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 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 diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/ViewImageFragment.kt b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewImageFragment.kt index 21245f688..6a18eca2e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/ViewImageFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewImageFragment.kt @@ -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() + private var transition: CompletableDeferred? = 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) } } diff --git a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt index 4871a454b..51e3d983d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt +++ b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt @@ -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? = null - ): Response> - - @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? - ): Single>> + ): Response> /** Fetch a single notification */ @GET("api/v1/notifications/{id}") @@ -185,10 +176,7 @@ interface MastodonApi { ): Response> @POST("api/v1/notifications/clear") - suspend fun clearNotifications(): Response - - @POST("api/v1/notifications/clear") - fun clearNotificationsOld(): Single + suspend fun clearNotifications(): NetworkResult @FormUrlEncoded @PUT("api/v1/media/{mediaId}") @@ -221,9 +209,6 @@ interface MastodonApi { @Body editedStatus: NewStatus ): NetworkResult - @GET("api/v1/statuses/{id}") - suspend fun statusAsync(@Path("id") statusId: String): NetworkResult - @GET("api/v1/statuses/{id}/source") suspend fun statusSource(@Path("id") statusId: String): NetworkResult @@ -266,24 +251,6 @@ interface MastodonApi { @POST("api/v1/statuses/{id}/unbookmark") suspend fun unbookmarkStatus(@Path("id") statusId: String): NetworkResult - @POST("api/v1/statuses/{id}/reblog") - fun reblogStatusOld(@Path("id") statusId: String): Single - - @POST("api/v1/statuses/{id}/unreblog") - fun unreblogStatusOld(@Path("id") statusId: String): Single - - @POST("api/v1/statuses/{id}/favourite") - fun favouriteStatusOld(@Path("id") statusId: String): Single - - @POST("api/v1/statuses/{id}/unfavourite") - fun unfavouriteStatusOld(@Path("id") statusId: String): Single - - @POST("api/v1/statuses/{id}/bookmark") - fun bookmarkStatusOld(@Path("id") statusId: String): Single - - @POST("api/v1/statuses/{id}/unbookmark") - fun unbookmarkStatusOld(@Path("id") statusId: String): Single - @POST("api/v1/statuses/{id}/pin") suspend fun pinStatus(@Path("id") statusId: String): NetworkResult @@ -296,17 +263,11 @@ interface MastodonApi { @POST("api/v1/statuses/{id}/unmute") suspend fun unmuteConversation(@Path("id") statusId: String): NetworkResult - @POST("api/v1/statuses/{id}/mute") - fun muteConversationOld(@Path("id") statusId: String): Single - - @POST("api/v1/statuses/{id}/unmute") - fun unmuteConversationOld(@Path("id") statusId: String): Single - @GET("api/v1/scheduled_statuses") - fun scheduledStatuses( + suspend fun scheduledStatuses( @Query("limit") limit: Int? = null, @Query("max_id") maxId: String? = null - ): Single> + ): NetworkResult> @DELETE("api/v1/scheduled_statuses/{id}") suspend fun deleteScheduledStatus( @@ -353,14 +314,6 @@ interface MastodonApi { @Query("following") following: Boolean? = null ): NetworkResult> - @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> - @GET("api/v1/accounts/{id}") suspend fun account(@Path("id") accountId: String): NetworkResult @@ -475,10 +428,10 @@ interface MastodonApi { suspend fun followRequests(@Query("max_id") maxId: String?): Response> @POST("api/v1/follow_requests/{id}/authorize") - fun authorizeFollowRequest(@Path("id") accountId: String): Single + suspend fun authorizeFollowRequest(@Path("id") accountId: String): NetworkResult @POST("api/v1/follow_requests/{id}/reject") - fun rejectFollowRequest(@Path("id") accountId: String): Single + suspend fun rejectFollowRequest(@Path("id") accountId: String): NetworkResult @FormUrlEncoded @POST("api/v1/apps") @@ -641,10 +594,6 @@ interface MastodonApi { @Field("choices[]") choices: List ): NetworkResult - @FormUrlEncoded - @POST("api/v1/polls/{id}/votes") - fun voteInPollOld(@Path("id") id: String, @Field("choices[]") choices: List): Single - @GET("api/v1/announcements") suspend fun listAnnouncements( @Query("with_dismissed") withDismissed: Boolean = true @@ -675,30 +624,17 @@ interface MastodonApi { ): NetworkResult @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> - - @GET("api/v1/statuses/{id}") - fun statusObservable(@Path("id") statusId: String): Single + ): NetworkResult> @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 - - @GET("api/v2/search") - fun searchSync( + suspend fun search( @Query("q") query: String?, @Query("type") type: String? = null, @Query("resolve") resolve: Boolean? = null, diff --git a/app/src/main/java/com/keylesspalace/tusky/receiver/NotificationBlockStateBroadcastReceiver.kt b/app/src/main/java/com/keylesspalace/tusky/receiver/NotificationBlockStateBroadcastReceiver.kt index c1636e921..a2656d0f1 100644 --- a/app/src/main/java/com/keylesspalace/tusky/receiver/NotificationBlockStateBroadcastReceiver.kt +++ b/app/src/main/java/com/keylesspalace/tusky/receiver/NotificationBlockStateBroadcastReceiver.kt @@ -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, diff --git a/app/src/main/java/com/keylesspalace/tusky/receiver/UnifiedPushBroadcastReceiver.kt b/app/src/main/java/com/keylesspalace/tusky/receiver/UnifiedPushBroadcastReceiver.kt index 174aca5aa..40fe48438 100644 --- a/app/src/main/java/com/keylesspalace/tusky/receiver/UnifiedPushBroadcastReceiver.kt +++ b/app/src/main/java/com/keylesspalace/tusky/receiver/UnifiedPushBroadcastReceiver.kt @@ -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) } } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/usecase/LogoutUsecase.kt b/app/src/main/java/com/keylesspalace/tusky/usecase/LogoutUsecase.kt index f8d3b11ca..1bcab4179 100644 --- a/app/src/main/java/com/keylesspalace/tusky/usecase/LogoutUsecase.kt +++ b/app/src/main/java/com/keylesspalace/tusky/usecase/LogoutUsecase.kt @@ -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 } diff --git a/app/src/main/java/com/keylesspalace/tusky/usecase/TimelineCases.kt b/app/src/main/java/com/keylesspalace/tusky/usecase/TimelineCases.kt index d34f0e383..acb60b7a5 100644 --- a/app/src/main/java/com/keylesspalace/tusky/usecase/TimelineCases.kt +++ b/app/src/main/java/com/keylesspalace/tusky/usecase/TimelineCases.kt @@ -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 { + return Single { reblog(statusId, reblog) } + } + suspend fun favourite(statusId: String, favourite: Boolean): NetworkResult { return if (favourite) { mastodonApi.favouriteStatus(statusId) @@ -71,6 +79,10 @@ class TimelineCases @Inject constructor( } } + fun favouriteOld(statusId: String, favourite: Boolean): Single { + return Single { favourite(statusId, favourite) } + } + suspend fun bookmark(statusId: String, bookmark: Boolean): NetworkResult { return if (bookmark) { mastodonApi.bookmarkStatus(statusId) @@ -81,6 +93,10 @@ class TimelineCases @Inject constructor( } } + fun bookmarkOld(statusId: String, bookmark: Boolean): Single { + return Single { bookmark(statusId, bookmark) } + } + suspend fun muteConversation(statusId: String, mute: Boolean): NetworkResult { return if (mute) { mastodonApi.muteConversation(statusId) @@ -91,50 +107,6 @@ class TimelineCases @Inject constructor( } } - fun reblogOld(statusId: String, reblog: Boolean): Single { - 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 { - 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 { - 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 { - 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): Single { - 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 { - return mastodonApi.authorizeFollowRequest(accountId) + fun acceptFollowRequestOld(accountId: String): Single { + return Single { mastodonApi.authorizeFollowRequest(accountId) } } - fun rejectFollowRequest(accountId: String): Single { - return mastodonApi.rejectFollowRequest(accountId) + fun rejectFollowRequestOld(accountId: String): Single { + return Single { mastodonApi.rejectFollowRequest(accountId) } + } + + fun notificationsOld( + maxId: String?, + sinceId: String?, + limit: Int?, + excludes: Set? + ): Single>> { + return Single { runCatching { mastodonApi.notifications(maxId, sinceId, limit, excludes) } } + } + + fun clearNotificationsOld(): Single { + return Single { mastodonApi.clearNotifications() } } companion object { diff --git a/app/src/main/java/com/keylesspalace/tusky/util/GlideExtensions.kt b/app/src/main/java/com/keylesspalace/tusky/util/GlideExtensions.kt new file mode 100644 index 000000000..bbad02f5f --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/GlideExtensions.kt @@ -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 RequestBuilder.submitAsync( + width: Int = Int.MIN_VALUE, + height: Int = Int.MIN_VALUE +): R { + return suspendCancellableCoroutine { continuation -> + val target = addListener( + object : RequestListener { + override fun onLoadFailed( + e: GlideException?, + model: Any?, + target: Target, + isFirstResource: Boolean + ): Boolean { + continuation.resumeWithException(e ?: IOException("Image loading failed")) + return false + } + + override fun onResourceReady( + resource: R & Any, + model: Any, + target: Target?, + dataSource: DataSource, + isFirstResource: Boolean + ): Boolean { + continuation.resume(resource) + return false + } + } + ).submit(width, height) + continuation.invokeOnCancellation { target.cancel(true) } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/MediaUtils.kt b/app/src/main/java/com/keylesspalace/tusky/util/MediaUtils.kt index 84404bcc8..c9c0a8b9f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/MediaUtils.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/MediaUtils.kt @@ -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 } diff --git a/app/src/main/java/com/keylesspalace/tusky/util/RelativeTimeUpdater.kt b/app/src/main/java/com/keylesspalace/tusky/util/RelativeTimeUpdater.kt new file mode 100644 index 000000000..a4dd85748 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/RelativeTimeUpdater.kt @@ -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) + } + } + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ShareShortcutHelper.kt b/app/src/main/java/com/keylesspalace/tusky/util/ShareShortcutHelper.kt index 6a13a851c..11f89255e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/ShareShortcutHelper.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/ShareShortcutHelper.kt @@ -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())) + } } diff --git a/app/src/main/java/com/keylesspalace/tusky/util/Single.kt b/app/src/main/java/com/keylesspalace/tusky/util/Single.kt new file mode 100644 index 000000000..d77bdb5a7 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/Single.kt @@ -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(private val producer: suspend CoroutineScope.() -> NetworkResult) { + fun subscribe( + owner: LifecycleOwner, + onSuccess: Consumer, + onError: Consumer + ): Job { + return owner.lifecycleScope.launch { + producer().fold( + onSuccess = { onSuccess.accept(it) }, + onFailure = { onError.accept(it) } + ) + } + } +} diff --git a/app/src/main/res/layout/activity_license.xml b/app/src/main/res/layout/activity_license.xml index 5256d93a8..43c860775 100644 --- a/app/src/main/res/layout/activity_license.xml +++ b/app/src/main/res/layout/activity_license.xml @@ -124,26 +124,6 @@ license:link="https://google.github.io/dagger/" license:name="Dagger 2" /> - - - -