introduce KotlinResultCallAdapter for nice suspending network calls (#2415)

* introduce KotlinResultCallAdapter for nice suspending network calls

* fix tests
This commit is contained in:
Konrad Pozniak 2022-04-14 19:49:49 +02:00 committed by GitHub
commit 3e8c6a318a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 168 additions and 179 deletions

View file

@ -682,18 +682,15 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
}
}
private fun fetchUserInfo() {
mastodonApi.accountVerifyCredentials()
.observeOn(AndroidSchedulers.mainThread())
.autoDispose(this, Lifecycle.Event.ON_DESTROY)
.subscribe(
{ userInfo ->
onFetchUserInfoSuccess(userInfo)
},
{ throwable ->
Log.e(TAG, "Failed to fetch user info. " + throwable.message)
}
)
private fun fetchUserInfo() = lifecycleScope.launch {
mastodonApi.accountVerifyCredentials().fold(
{ userInfo ->
onFetchUserInfoSuccess(userInfo)
},
{ throwable ->
Log.e(TAG, "Failed to fetch user info. " + throwable.message)
}
)
}
private fun onFetchUserInfoSuccess(me: Account) {

View file

@ -35,6 +35,7 @@ import com.keylesspalace.tusky.util.Resource
import com.keylesspalace.tusky.util.RxAwareViewModel
import com.keylesspalace.tusky.util.Success
import io.reactivex.rxjava3.core.Single
import kotlinx.coroutines.rx3.rxSingle
import javax.inject.Inject
class AnnouncementsViewModel @Inject constructor(
@ -56,8 +57,9 @@ class AnnouncementsViewModel @Inject constructor(
appDatabase.instanceDao().loadMetadataForInstance(accountManager.activeAccount?.domain!!)
.map<Either<InstanceEntity, Instance>> { Either.Left(it) }
.onErrorResumeNext {
mastodonApi.getInstance()
.map { Either.Right(it) }
rxSingle {
mastodonApi.getInstance().getOrThrow()
}.map { Either.Right(it) }
}
) { emojis, either ->
either.asLeftOrNull()?.copy(emojiList = emojis)

View file

@ -48,6 +48,7 @@ import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.disposables.Disposable
import kotlinx.coroutines.launch
import kotlinx.coroutines.rx3.rxSingle
import java.util.Locale
import javax.inject.Inject
@ -105,7 +106,10 @@ class ComposeViewModel @Inject constructor(
init {
Single.zip(
api.getCustomEmojis(), api.getInstance()
api.getCustomEmojis(),
rxSingle {
api.getInstance().getOrThrow()
}
) { emojis, instance ->
InstanceEntity(
instance = accountManager.activeAccount?.domain!!,
@ -291,7 +295,7 @@ class ComposeViewModel @Inject constructor(
): LiveData<Unit> {
val deletionObservable = if (isEditingScheduledToot) {
api.deleteScheduledStatus(scheduledTootId.toString()).toObservable().map { }
rxSingle { api.deleteScheduledStatus(scheduledTootId.toString()) }.toObservable().map { }
} else {
Observable.just(Unit)
}.toLiveData()

View file

@ -33,7 +33,6 @@ import com.keylesspalace.tusky.MainActivity
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.databinding.ActivityLoginBinding
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.entity.AppCredentials
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.getNonNullString
import com.keylesspalace.tusky.util.rickRoll
@ -166,32 +165,33 @@ class LoginActivity : BaseActivity(), Injectable {
setLoading(true)
lifecycleScope.launch {
val credentials: AppCredentials = try {
mastodonApi.authenticateApp(
domain, getString(R.string.app_name), oauthRedirectUri,
OAUTH_SCOPES, getString(R.string.tusky_website)
)
} catch (e: Exception) {
binding.loginButton.isEnabled = true
binding.domainTextInputLayout.error =
getString(R.string.error_failed_app_registration)
setLoading(false)
Log.e(TAG, Log.getStackTraceString(e))
return@launch
}
mastodonApi.authenticateApp(
domain, getString(R.string.app_name), oauthRedirectUri,
OAUTH_SCOPES, getString(R.string.tusky_website)
).fold(
{ credentials ->
// Before we open browser page we save the data.
// Even if we don't open other apps user may go to password manager or somewhere else
// and we will need to pick up the process where we left off.
// Alternatively we could pass it all as part of the intent and receive it back
// but it is a bit of a workaround.
preferences.edit()
.putString(DOMAIN, domain)
.putString(CLIENT_ID, credentials.clientId)
.putString(CLIENT_SECRET, credentials.clientSecret)
.apply()
// Before we open browser page we save the data.
// Even if we don't open other apps user may go to password manager or somewhere else
// and we will need to pick up the process where we left off.
// Alternatively we could pass it all as part of the intent and receive it back
// but it is a bit of a workaround.
preferences.edit()
.putString(DOMAIN, domain)
.putString(CLIENT_ID, credentials.clientId)
.putString(CLIENT_SECRET, credentials.clientSecret)
.apply()
redirectUserToAuthorizeAndLogin(domain, credentials.clientId)
redirectUserToAuthorizeAndLogin(domain, credentials.clientId)
},
{ e ->
binding.loginButton.isEnabled = true
binding.domainTextInputLayout.error =
getString(R.string.error_failed_app_registration)
setLoading(false)
Log.e(TAG, Log.getStackTraceString(e))
return@launch
}
)
}
}
@ -224,29 +224,28 @@ class LoginActivity : BaseActivity(), Injectable {
setLoading(true)
val accessToken = try {
mastodonApi.fetchOAuthToken(
domain, clientId, clientSecret, oauthRedirectUri, code,
"authorization_code"
)
} catch (e: Exception) {
setLoading(false)
binding.domainTextInputLayout.error =
getString(R.string.error_retrieving_oauth_token)
Log.e(
TAG,
"%s %s".format(getString(R.string.error_retrieving_oauth_token), e.message),
)
return
}
mastodonApi.fetchOAuthToken(
domain, clientId, clientSecret, oauthRedirectUri, code, "authorization_code"
).fold(
{ accessToken ->
accountManager.addAccount(accessToken.accessToken, domain)
accountManager.addAccount(accessToken.accessToken, domain)
val intent = Intent(this, MainActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
startActivity(intent)
finish()
overridePendingTransition(R.anim.explode, R.anim.explode)
val intent = Intent(this, MainActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
startActivity(intent)
finish()
overridePendingTransition(R.anim.explode, R.anim.explode)
},
{ e ->
setLoading(false)
binding.domainTextInputLayout.error =
getString(R.string.error_retrieving_oauth_token)
Log.e(
TAG,
"%s %s".format(getString(R.string.error_retrieving_oauth_token), e.message),
)
}
)
}
private fun setLoading(loadingState: Boolean) {

View file

@ -25,7 +25,6 @@ import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.entity.ScheduledStatus
import com.keylesspalace.tusky.network.MastodonApi
import kotlinx.coroutines.launch
import kotlinx.coroutines.rx3.await
import javax.inject.Inject
class ScheduledStatusViewModel @Inject constructor(
@ -43,12 +42,14 @@ class ScheduledStatusViewModel @Inject constructor(
fun deleteScheduledStatus(status: ScheduledStatus) {
viewModelScope.launch {
try {
mastodonApi.deleteScheduledStatus(status.id).await()
pagingSourceFactory.remove(status)
} catch (throwable: Throwable) {
Log.w("ScheduledTootViewModel", "Error deleting scheduled status", throwable)
}
mastodonApi.deleteScheduledStatus(status.id).fold(
{
pagingSourceFactory.remove(status)
},
{ throwable ->
Log.w("ScheduledTootViewModel", "Error deleting scheduled status", throwable)
}
)
}
}
}

View file

@ -19,6 +19,7 @@ import android.content.Context
import android.content.SharedPreferences
import android.os.Build
import android.text.Spanned
import at.connyduck.calladapter.kotlinresult.KotlinResultCallAdapterFactory
import com.google.gson.Gson
import com.google.gson.GsonBuilder
import com.keylesspalace.tusky.BuildConfig
@ -111,6 +112,7 @@ class NetworkModule {
.client(httpClient)
.addConverterFactory(GsonConverterFactory.create(gson))
.addCallAdapterFactory(RxJava3CallAdapterFactory.create())
.addCallAdapterFactory(KotlinResultCallAdapterFactory.create())
.build()
}

View file

@ -80,7 +80,7 @@ interface MastodonApi {
fun getCustomEmojis(): Single<List<Emoji>>
@GET("api/v1/instance")
fun getInstance(): Single<Instance>
suspend fun getInstance(): Result<Instance>
@GET("api/v1/filters")
fun getFilters(): Single<List<Filter>>
@ -249,12 +249,12 @@ interface MastodonApi {
): Single<List<ScheduledStatus>>
@DELETE("api/v1/scheduled_statuses/{id}")
fun deleteScheduledStatus(
suspend fun deleteScheduledStatus(
@Path("id") scheduledStatusId: String
): Single<ResponseBody>
): Result<ResponseBody>
@GET("api/v1/accounts/verify_credentials")
fun accountVerifyCredentials(): Single<Account>
suspend fun accountVerifyCredentials(): Result<Account>
@FormUrlEncoded
@PATCH("api/v1/accounts/update_credentials")
@ -265,7 +265,7 @@ interface MastodonApi {
@Multipart
@PATCH("api/v1/accounts/update_credentials")
fun accountUpdateCredentials(
suspend fun accountUpdateCredentials(
@Part(value = "display_name") displayName: RequestBody?,
@Part(value = "note") note: RequestBody?,
@Part(value = "locked") locked: RequestBody?,
@ -279,7 +279,7 @@ interface MastodonApi {
@Part(value = "fields_attributes[2][value]") fieldValue2: RequestBody?,
@Part(value = "fields_attributes[3][name]") fieldName3: RequestBody?,
@Part(value = "fields_attributes[3][value]") fieldValue3: RequestBody?
): Call<Account>
): Result<Account>
@GET("api/v1/accounts/search")
fun searchAccounts(
@ -447,7 +447,7 @@ interface MastodonApi {
@Field("redirect_uris") redirectUris: String,
@Field("scopes") scopes: String,
@Field("website") website: String
): AppCredentials
): Result<AppCredentials>
@FormUrlEncoded
@POST("oauth/token")
@ -458,7 +458,7 @@ interface MastodonApi {
@Field("redirect_uri") redirectUri: String,
@Field("code") code: String,
@Field("grant_type") grantType: String
): AccessToken
): Result<AccessToken>
@FormUrlEncoded
@POST("api/v1/lists")

View file

@ -20,6 +20,7 @@ import android.net.Uri
import androidx.core.net.toUri
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.ProfileEditedEvent
import com.keylesspalace.tusky.entity.Account
@ -31,8 +32,7 @@ import com.keylesspalace.tusky.util.Loading
import com.keylesspalace.tusky.util.Resource
import com.keylesspalace.tusky.util.Success
import com.keylesspalace.tusky.util.randomAlphanumericString
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.addTo
import kotlinx.coroutines.launch
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody
import okhttp3.RequestBody
@ -40,9 +40,7 @@ import okhttp3.RequestBody.Companion.asRequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONException
import org.json.JSONObject
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response
import retrofit2.HttpException
import java.io.File
import javax.inject.Inject
@ -63,24 +61,20 @@ class EditProfileViewModel @Inject constructor(
private var oldProfileData: Account? = null
private val disposables = CompositeDisposable()
fun obtainProfile() {
fun obtainProfile() = viewModelScope.launch {
if (profileData.value == null || profileData.value is Error) {
profileData.postValue(Loading())
mastodonApi.accountVerifyCredentials()
.subscribe(
{ profile ->
oldProfileData = profile
profileData.postValue(Success(profile))
},
{
profileData.postValue(Error())
}
)
.addTo(disposables)
mastodonApi.accountVerifyCredentials().fold(
{ profile ->
oldProfileData = profile
profileData.postValue(Success(profile))
},
{
profileData.postValue(Error())
}
)
}
}
@ -151,34 +145,34 @@ class EditProfileViewModel @Inject constructor(
return
}
mastodonApi.accountUpdateCredentials(
displayName, note, locked, avatar, header,
field1?.first, field1?.second, field2?.first, field2?.second, field3?.first, field3?.second, field4?.first, field4?.second
).enqueue(object : Callback<Account> {
override fun onResponse(call: Call<Account>, response: Response<Account>) {
val newProfileData = response.body()
if (!response.isSuccessful || newProfileData == null) {
val errorResponse = response.errorBody()?.string()
val errorMsg = if (!errorResponse.isNullOrBlank()) {
try {
JSONObject(errorResponse).optString("error", null)
} catch (e: JSONException) {
viewModelScope.launch {
mastodonApi.accountUpdateCredentials(
displayName, note, locked, avatar, header,
field1?.first, field1?.second, field2?.first, field2?.second, field3?.first, field3?.second, field4?.first, field4?.second
).fold(
{ newProfileData ->
saveData.postValue(Success())
eventHub.dispatch(ProfileEditedEvent(newProfileData))
},
{ throwable ->
if (throwable is HttpException) {
val errorResponse = throwable.response()?.errorBody()?.string()
val errorMsg = if (!errorResponse.isNullOrBlank()) {
try {
JSONObject(errorResponse).optString("error", "")
} catch (e: JSONException) {
null
}
} else {
null
}
saveData.postValue(Error(errorMessage = errorMsg))
} else {
null
saveData.postValue(Error())
}
saveData.postValue(Error(errorMessage = errorMsg))
return
}
saveData.postValue(Success())
eventHub.dispatch(ProfileEditedEvent(newProfileData))
}
override fun onFailure(call: Call<Account>, t: Throwable) {
saveData.postValue(Error())
}
})
)
}
}
// cache activity state for rotation change
@ -208,15 +202,11 @@ class EditProfileViewModel @Inject constructor(
return File(application.cacheDir, filename)
}
override fun onCleared() {
disposables.dispose()
}
fun obtainInstance() {
fun obtainInstance() = viewModelScope.launch {
if (instanceData.value == null || instanceData.value is Error) {
instanceData.postValue(Loading())
mastodonApi.getInstance().subscribe(
mastodonApi.getInstance().fold(
{ instance ->
instanceData.postValue(Success(instance))
},
@ -224,7 +214,6 @@ class EditProfileViewModel @Inject constructor(
instanceData.postValue(Error())
}
)
.addTo(disposables)
}
}
}