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" /> - - - -