diff --git a/app/src/main/java/com/keylesspalace/tusky/AccountsInListFragment.kt b/app/src/main/java/com/keylesspalace/tusky/AccountsInListFragment.kt index 03a74458..38d72fc3 100644 --- a/app/src/main/java/com/keylesspalace/tusky/AccountsInListFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/AccountsInListFragment.kt @@ -24,12 +24,11 @@ import android.widget.LinearLayout import androidx.appcompat.widget.SearchView import androidx.fragment.app.DialogFragment import androidx.fragment.app.viewModels +import androidx.lifecycle.lifecycleScope import androidx.preference.PreferenceManager import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.ListAdapter -import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider.from -import autodispose2.autoDispose import com.keylesspalace.tusky.databinding.FragmentAccountsInListBinding import com.keylesspalace.tusky.databinding.ItemFollowRequestBinding import com.keylesspalace.tusky.di.Injectable @@ -45,7 +44,7 @@ import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.viewmodel.AccountsInListViewModel import com.keylesspalace.tusky.viewmodel.State -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import kotlinx.coroutines.launch import java.io.IOException import javax.inject.Inject @@ -98,10 +97,8 @@ class AccountsInListFragment : DialogFragment(), Injectable { binding.accountsSearchRecycler.layoutManager = LinearLayoutManager(view.context) binding.accountsSearchRecycler.adapter = searchAdapter - viewModel.state - .observeOn(AndroidSchedulers.mainThread()) - .autoDispose(from(this)) - .subscribe { state -> + viewLifecycleOwner.lifecycleScope.launch { + viewModel.state.collect { state -> adapter.submitList(state.accounts.asRightOrNull() ?: listOf()) when (state.accounts) { @@ -111,6 +108,7 @@ class AccountsInListFragment : DialogFragment(), Injectable { setupSearchView(state) } + } binding.searchView.isSubmitButtonEnabled = true binding.searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener { diff --git a/app/src/main/java/com/keylesspalace/tusky/ListsActivity.kt b/app/src/main/java/com/keylesspalace/tusky/ListsActivity.kt index f1e17a51..5850e321 100644 --- a/app/src/main/java/com/keylesspalace/tusky/ListsActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/ListsActivity.kt @@ -31,14 +31,13 @@ import android.widget.TextView import androidx.activity.viewModels import androidx.annotation.StringRes import androidx.appcompat.app.AlertDialog +import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView import at.connyduck.sparkbutton.helpers.Utils -import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider.from -import autodispose2.autoDispose import com.google.android.material.snackbar.Snackbar import com.keylesspalace.tusky.databinding.ActivityListsBinding import com.keylesspalace.tusky.di.Injectable @@ -63,7 +62,7 @@ import com.mikepenz.iconics.utils.colorInt import com.mikepenz.iconics.utils.sizeDp import dagger.android.DispatchingAndroidInjector import dagger.android.HasAndroidInjector -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import kotlinx.coroutines.launch import javax.inject.Inject /** @@ -102,19 +101,18 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector { DividerItemDecoration(this, DividerItemDecoration.VERTICAL) ) - viewModel.state - .observeOn(AndroidSchedulers.mainThread()) - .autoDispose(from(this)) - .subscribe(this::update) + lifecycleScope.launch { + viewModel.state.collect(this@ListsActivity::update) + } + viewModel.retryLoading() binding.addListButton.setOnClickListener { showlistNameDialog(null) } - viewModel.events.observeOn(AndroidSchedulers.mainThread()) - .autoDispose(from(this)) - .subscribe { event -> + lifecycleScope.launch { + viewModel.events.collect { event -> @Suppress("WHEN_ENUM_CAN_BE_NULL_IN_JAVA") when (event) { Event.CREATE_ERROR -> showMessage(R.string.error_create_list) @@ -122,6 +120,7 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector { Event.DELETE_ERROR -> showMessage(R.string.error_delete_list) } } + } } private fun showlistNameDialog(list: MastoList?) { diff --git a/app/src/main/java/com/keylesspalace/tusky/TabPreferenceActivity.kt b/app/src/main/java/com/keylesspalace/tusky/TabPreferenceActivity.kt index 720664bd..b900e756 100644 --- a/app/src/main/java/com/keylesspalace/tusky/TabPreferenceActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/TabPreferenceActivity.kt @@ -25,6 +25,7 @@ import androidx.appcompat.widget.AppCompatEditText import androidx.core.view.isVisible import androidx.core.view.updatePadding import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.LinearLayoutManager @@ -46,9 +47,9 @@ import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.util.onTextChanged import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.util.visible -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.core.Single import io.reactivex.rxjava3.schedulers.Schedulers +import kotlinx.coroutines.launch import java.util.regex.Pattern import javax.inject.Inject @@ -253,10 +254,8 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene private fun showSelectListDialog() { val adapter = ListSelectionAdapter(this) - mastodonApi.getLists() - .observeOn(AndroidSchedulers.mainThread()) - .autoDispose(from(this, Lifecycle.Event.ON_DESTROY)) - .subscribe( + lifecycleScope.launch { + mastodonApi.getLists().fold( { lists -> adapter.addAll(lists) }, @@ -264,6 +263,7 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene Log.e("TabPreferenceActivity", "failed to load lists", throwable) } ) + } AlertDialog.Builder(this) .setTitle(R.string.select_list_title) 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 3a34169c..0d9a1945 100644 --- a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt +++ b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt @@ -38,7 +38,6 @@ import com.keylesspalace.tusky.entity.SearchResult import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.StatusContext import com.keylesspalace.tusky.entity.TimelineAccount -import io.reactivex.rxjava3.core.Completable import io.reactivex.rxjava3.core.Single import okhttp3.MultipartBody import okhttp3.RequestBody @@ -74,9 +73,6 @@ interface MastodonApi { const val PLACEHOLDER_DOMAIN = "dummy.placeholder" } - @GET("/api/v1/lists") - fun getLists(): Single> - @GET("/api/v1/custom_emojis") suspend fun getCustomEmojis(): Result> @@ -281,12 +277,12 @@ interface MastodonApi { ): Result @GET("api/v1/accounts/search") - fun searchAccounts( + suspend fun searchAccounts( @Query("q") query: String, @Query("resolve") resolve: Boolean? = null, @Query("limit") limit: Int? = null, @Query("following") following: Boolean? = null - ): Single> + ): Result> @GET("api/v1/accounts/search") fun searchAccountsCall( @@ -462,44 +458,47 @@ interface MastodonApi { @Field("grant_type") grantType: String ): Result + @GET("/api/v1/lists") + suspend fun getLists(): Result> + @FormUrlEncoded @POST("api/v1/lists") - fun createList( + suspend fun createList( @Field("title") title: String - ): Single + ): Result @FormUrlEncoded @PUT("api/v1/lists/{listId}") - fun updateList( + suspend fun updateList( @Path("listId") listId: String, @Field("title") title: String - ): Single + ): Result @DELETE("api/v1/lists/{listId}") - fun deleteList( + suspend fun deleteList( @Path("listId") listId: String - ): Completable + ): Result @GET("api/v1/lists/{listId}/accounts") - fun getAccountsInList( + suspend fun getAccountsInList( @Path("listId") listId: String, @Query("limit") limit: Int - ): Single> + ): Result> @FormUrlEncoded // @DELETE doesn't support fields @HTTP(method = "DELETE", path = "api/v1/lists/{listId}/accounts", hasBody = true) - fun deleteAccountFromList( + suspend fun deleteAccountFromList( @Path("listId") listId: String, @Field("account_ids[]") accountIds: List - ): Completable + ): Result @FormUrlEncoded @POST("api/v1/lists/{listId}/accounts") - fun addCountToList( + suspend fun addAccountToList( @Path("listId") listId: String, @Field("account_ids[]") accountIds: List - ): Completable + ): Result @GET("/api/v1/conversations") suspend fun getConversations( diff --git a/app/src/main/java/com/keylesspalace/tusky/viewmodel/AccountsInListViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/viewmodel/AccountsInListViewModel.kt index fd989376..184debb9 100644 --- a/app/src/main/java/com/keylesspalace/tusky/viewmodel/AccountsInListViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/viewmodel/AccountsInListViewModel.kt @@ -17,92 +17,103 @@ package com.keylesspalace.tusky.viewmodel import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import com.keylesspalace.tusky.entity.TimelineAccount import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.util.Either import com.keylesspalace.tusky.util.Either.Left import com.keylesspalace.tusky.util.Either.Right -import com.keylesspalace.tusky.util.RxAwareViewModel import com.keylesspalace.tusky.util.withoutFirstWhich -import io.reactivex.rxjava3.core.Observable -import io.reactivex.rxjava3.subjects.BehaviorSubject +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch import javax.inject.Inject data class State(val accounts: Either>, val searchResult: List?) -class AccountsInListViewModel @Inject constructor(private val api: MastodonApi) : RxAwareViewModel() { +class AccountsInListViewModel @Inject constructor(private val api: MastodonApi) : ViewModel() { - val state: Observable get() = _state - private val _state = BehaviorSubject.createDefault(State(Right(listOf()), null)) + val state: Flow get() = _state + private val _state = MutableStateFlow(State(Right(listOf()), null)) fun load(listId: String) { - val state = _state.value!! + val state = _state.value if (state.accounts.isLeft() || state.accounts.asRight().isEmpty()) { - api.getAccountsInList(listId, 0).subscribe( - { accounts -> - updateState { copy(accounts = Right(accounts)) } - }, - { e -> - updateState { copy(accounts = Left(e)) } - } - ).autoDispose() + viewModelScope.launch { + api.getAccountsInList(listId, 0).fold( + { accounts -> + updateState { copy(accounts = Right(accounts)) } + }, + { e -> + updateState { copy(accounts = Left(e)) } + } + ) + } } } fun addAccountToList(listId: String, account: TimelineAccount) { - api.addCountToList(listId, listOf(account.id)) - .subscribe( - { - updateState { - copy(accounts = accounts.map { it + account }) + viewModelScope.launch { + api.addAccountToList(listId, listOf(account.id)) + .fold( + { + updateState { + copy(accounts = accounts.map { it + account }) + } + }, + { + Log.i( + javaClass.simpleName, + "Failed to add account to list: ${account.username}" + ) } - }, - { - Log.i( - javaClass.simpleName, - "Failed to add account to the list: ${account.username}" - ) - } - ) - .autoDispose() + ) + } } fun deleteAccountFromList(listId: String, accountId: String) { - api.deleteAccountFromList(listId, listOf(accountId)) - .subscribe( - { - updateState { - copy( - accounts = accounts.map { accounts -> - accounts.withoutFirstWhich { it.id == accountId } - } + viewModelScope.launch { + api.deleteAccountFromList(listId, listOf(accountId)) + .fold( + { + updateState { + copy( + accounts = accounts.map { accounts -> + accounts.withoutFirstWhich { it.id == accountId } + } + ) + } + }, + { + Log.i( + javaClass.simpleName, + "Failed to remove account from list: $accountId" ) } - }, - { - Log.i(javaClass.simpleName, "Failed to remove account from thelist: $accountId") - } - ) - .autoDispose() + ) + } } fun search(query: String) { when { query.isEmpty() -> updateState { copy(searchResult = null) } query.isBlank() -> updateState { copy(searchResult = listOf()) } - else -> api.searchAccounts(query, null, 10, true) - .subscribe( - { result -> - updateState { copy(searchResult = result) } - }, - { - updateState { copy(searchResult = listOf()) } - } - ).autoDispose() + else -> viewModelScope.launch { + api.searchAccounts(query, null, 10, true) + .fold( + { result -> + updateState { copy(searchResult = result) } + }, + { + updateState { copy(searchResult = listOf()) } + } + ) + } } } private inline fun updateState(crossinline fn: State.() -> State) { - _state.onNext(fn(_state.value!!)) + _state.value = fn(_state.value) } } diff --git a/app/src/main/java/com/keylesspalace/tusky/viewmodel/ListsViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/viewmodel/ListsViewModel.kt index 68263155..3b5b824b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/viewmodel/ListsViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/viewmodel/ListsViewModel.kt @@ -16,19 +16,22 @@ package com.keylesspalace.tusky.viewmodel +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import com.keylesspalace.tusky.entity.MastoList import com.keylesspalace.tusky.network.MastodonApi -import com.keylesspalace.tusky.util.RxAwareViewModel import com.keylesspalace.tusky.util.replacedFirstWhich import com.keylesspalace.tusky.util.withoutFirstWhich -import io.reactivex.rxjava3.core.Observable -import io.reactivex.rxjava3.subjects.BehaviorSubject -import io.reactivex.rxjava3.subjects.PublishSubject +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch import java.io.IOException import java.net.ConnectException import javax.inject.Inject -internal class ListsViewModel @Inject constructor(private val api: MastodonApi) : RxAwareViewModel() { +internal class ListsViewModel @Inject constructor(private val api: MastodonApi) : ViewModel() { enum class LoadingState { INITIAL, LOADING, LOADED, ERROR_NETWORK, ERROR_OTHER } @@ -39,86 +42,94 @@ internal class ListsViewModel @Inject constructor(private val api: MastodonApi) data class State(val lists: List, val loadingState: LoadingState) - val state: Observable get() = _state - val events: Observable get() = _events - private val _state = BehaviorSubject.createDefault(State(listOf(), LoadingState.INITIAL)) - private val _events = PublishSubject.create() + val state: Flow get() = _state + val events: Flow get() = _events + private val _state = MutableStateFlow(State(listOf(), LoadingState.INITIAL)) + private val _events = MutableSharedFlow(replay = 0, extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) fun retryLoading() { loadIfNeeded() } private fun loadIfNeeded() { - val state = _state.value!! + val state = _state.value if (state.loadingState == LoadingState.LOADING || state.lists.isNotEmpty()) return updateState { copy(loadingState = LoadingState.LOADING) } - api.getLists().subscribe( - { lists -> - updateState { - copy( - lists = lists, - loadingState = LoadingState.LOADED - ) + viewModelScope.launch { + api.getLists().fold( + { lists -> + updateState { + copy( + lists = lists, + loadingState = LoadingState.LOADED + ) + } + }, + { err -> + updateState { + copy( + loadingState = if (err is IOException || err is ConnectException) + LoadingState.ERROR_NETWORK else LoadingState.ERROR_OTHER + ) + } } - }, - { err -> - updateState { - copy( - loadingState = if (err is IOException || err is ConnectException) - LoadingState.ERROR_NETWORK else LoadingState.ERROR_OTHER - ) - } - } - ).autoDispose() + ) + } } fun createNewList(listName: String) { - api.createList(listName).subscribe( - { list -> - updateState { - copy(lists = lists + list) + viewModelScope.launch { + api.createList(listName).fold( + { list -> + updateState { + copy(lists = lists + list) + } + }, + { + sendEvent(Event.CREATE_ERROR) } - }, - { - sendEvent(Event.CREATE_ERROR) - } - ).autoDispose() + ) + } } fun renameList(listId: String, listName: String) { - api.updateList(listId, listName).subscribe( - { list -> - updateState { - copy(lists = lists.replacedFirstWhich(list) { it.id == listId }) + viewModelScope.launch { + api.updateList(listId, listName).fold( + { list -> + updateState { + copy(lists = lists.replacedFirstWhich(list) { it.id == listId }) + } + }, + { + sendEvent(Event.RENAME_ERROR) } - }, - { - sendEvent(Event.RENAME_ERROR) - } - ).autoDispose() + ) + } } fun deleteList(listId: String) { - api.deleteList(listId).subscribe( - { - updateState { - copy(lists = lists.withoutFirstWhich { it.id == listId }) + viewModelScope.launch { + api.deleteList(listId).fold( + { + updateState { + copy(lists = lists.withoutFirstWhich { it.id == listId }) + } + }, + { + sendEvent(Event.DELETE_ERROR) } - }, - { - sendEvent(Event.DELETE_ERROR) - } - ).autoDispose() + ) + } } private inline fun updateState(crossinline fn: State.() -> State) { - _state.onNext(fn(_state.value!!)) + _state.value = fn(_state.value) } - private fun sendEvent(event: Event) { - _events.onNext(event) + private suspend fun sendEvent(event: Event) { + _events.emit(event) } }