feat: real api error handling (#27)

This commit is contained in:
Diamond 2022-10-16 21:20:34 -07:00 • committed by GitHub
parent 413c9fb92d
commit 3ad14acb56
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 292 additions and 81 deletions

View file

@ -1,5 +1,6 @@
package com.aliucord.manager.di
import com.aliucord.manager.network.service.HttpService
import io.ktor.client.*
import io.ktor.client.engine.okhttp.*
import io.ktor.client.plugins.contentnegotiation.*
@ -41,4 +42,5 @@ val httpModule = module {
singleOf(::provideJson)
singleOf(::provideHttpClient)
singleOf(::HttpService)
}

View file

@ -2,6 +2,7 @@ package com.aliucord.manager.domain.repository
import com.aliucord.manager.network.dto.GithubUser
import com.aliucord.manager.network.service.GithubService
import com.aliucord.manager.network.utils.transform
class GithubRepository(
private val service: GithubService
@ -9,7 +10,7 @@ class GithubRepository(
suspend fun getCommits(page: Int) = service.getCommits(page)
suspend fun getContributors() = service.getContributors()
.sortedByDescending(GithubUser::contributions)
.transform { it.sortedByDescending(GithubUser::contributions) }
suspend fun getVersion() = service.getVersion()

View file

@ -1,41 +1,46 @@
package com.aliucord.manager.network.service
import com.aliucord.manager.network.dto.*
import io.ktor.client.*
import io.ktor.client.call.*
import com.aliucord.manager.network.dto.Commit
import com.aliucord.manager.network.dto.GithubRelease
import com.aliucord.manager.network.dto.GithubUser
import com.aliucord.manager.network.dto.Version
import com.aliucord.manager.network.utils.ApiResponse
import io.ktor.client.request.*
import io.ktor.client.statement.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
class GithubService(
private val httpClient: HttpClient,
private val json: Json
private val http: HttpService,
) {
suspend fun getCommits(page: Int): List<Commit> = withContext(Dispatchers.IO) {
httpClient.get(COMMITS_URL) {
suspend fun getCommits(page: Int): ApiResponse<List<Commit>> = withContext(Dispatchers.IO) {
http.request {
url(COMMITS_URL)
parameter("page", page)
}.body()
}
}
suspend fun getVersion(): Version = withContext(Dispatchers.IO) {
// Return content type is plain text so manually decoding
val res = httpClient.get(DATA_URL).bodyAsText()
json.decodeFromString(res)
suspend fun getVersion(): ApiResponse<Version> = withContext(Dispatchers.IO) {
http.request {
url(DATA_URL)
}
}
suspend fun getContributors(): List<GithubUser> = withContext(Dispatchers.IO) {
httpClient.get(CONTRIBUTORS_URL).body()
suspend fun getContributors(): ApiResponse<List<GithubUser>> = withContext(Dispatchers.IO) {
http.request {
url(CONTRIBUTORS_URL)
}
}
suspend fun getHermesReleases(): List<GithubRelease> = withContext(Dispatchers.IO) {
httpClient.get(HERMES_RELEASES_URL).body()
suspend fun getHermesReleases(): ApiResponse<List<GithubRelease>> = withContext(Dispatchers.IO) {
http.request {
url(HERMES_RELEASES_URL)
}
}
suspend fun getAliucordNativeReleases(): List<GithubRelease> = withContext(Dispatchers.IO) {
httpClient.get(ALIUCORD_NATIVE_RELEASES_URL).body()
suspend fun getAliucordNativeReleases(): ApiResponse<List<GithubRelease>> = withContext(Dispatchers.IO) {
http.request {
url(ALIUCORD_NATIVE_RELEASES_URL)
}
}
companion object {
@ -46,6 +51,7 @@ class GithubService(
private const val COMMITS_URL = "https://api.github.com/repos/$ORG/$REPO/commits"
private const val CONTRIBUTORS_URL = "https://api.github.com/repos/$ORG/$REPO/contributors"
private const val DATA_URL = "https://raw.githubusercontent.com/$ORG/$REPO/builds/data.json"
private const val HERMES_RELEASES_URL = "https://api.github.com/repos/$ORG/$HERMES_REPO/releases"

View file

@ -0,0 +1,45 @@
package com.aliucord.manager.network.service
import android.util.Log
import com.aliucord.manager.BuildConfig
import com.aliucord.manager.network.utils.ApiError
import com.aliucord.manager.network.utils.ApiFailure
import com.aliucord.manager.network.utils.ApiResponse
import io.ktor.client.*
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
class HttpService(
val json: Json,
val http: HttpClient,
) {
suspend inline fun <reified T> request(builder: HttpRequestBuilder.() -> Unit = {}): ApiResponse<T> {
var body: String? = null
val response = try {
val response = http.request(builder)
if (response.status.isSuccess()) {
body = response.bodyAsText()
ApiResponse.Success(json.decodeFromString<T>(body))
} else {
body = try {
response.bodyAsText()
} catch (t: Throwable) {
null
}
Log.e(BuildConfig.TAG, "Failed to fetch: API error, http status: ${response.status}, body: $body")
ApiResponse.Error(ApiError(response.status, body))
}
} catch (t: Throwable) {
Log.e(BuildConfig.TAG, "Failed to fetch: error: $t, body: $body")
ApiResponse.Failure(ApiFailure(t, body))
}
return response
}
}

View file

@ -0,0 +1,48 @@
@file:Suppress("NOTHING_TO_INLINE")
package com.aliucord.manager.network.utils
import io.ktor.http.*
sealed interface ApiResponse<T> {
data class Success<T>(val data: T) : ApiResponse<T>
data class Error<T>(val error: ApiError) : ApiResponse<T>
data class Failure<T>(val error: ApiFailure) : ApiResponse<T>
}
class ApiError(code: HttpStatusCode, body: String?) : Error("HTTP Code $code, Body: $body")
class ApiFailure(error: Throwable, body: String?) : Error(body, error)
inline fun <T, R> ApiResponse<T>.fold(
success: (T) -> R,
error: (ApiError) -> R,
failure: (ApiFailure) -> R
): R {
return when (this) {
is ApiResponse.Success -> success(this.data)
is ApiResponse.Error -> error(this.error)
is ApiResponse.Failure -> failure(this.error)
}
}
inline fun <T, R> ApiResponse<T>.fold(
success: (T) -> R,
fail: (Error) -> R,
): R {
return when (this) {
is ApiResponse.Success -> success(data)
is ApiResponse.Error -> fail(error)
is ApiResponse.Failure -> fail(error)
}
}
@Suppress("UNCHECKED_CAST")
inline fun <T, R> ApiResponse<T>.transform(block: (T) -> R): ApiResponse<R> {
return if (this !is ApiResponse.Success) {
// Error and Failure do not use the generic value
this as ApiResponse<R>
} else {
ApiResponse.Success(block(data))
}
}

View file

@ -0,0 +1,36 @@
package com.aliucord.manager.ui.component
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.aliucord.manager.R
@Composable
fun LoadFailure(onRetry: () -> Unit) {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = stringResource(R.string.load_fail),
style = MaterialTheme.typography.labelLarge.copy(fontSize = 16.sp),
color = MaterialTheme.colorScheme.error
)
Button(
onClick = onRetry,
shape = MaterialTheme.shapes.extraLarge
) {
Text(text = stringResource(R.string.retry))
}
}
}

View file

@ -22,11 +22,13 @@ import androidx.paging.compose.items
import coil.compose.AsyncImage
import com.aliucord.manager.R
import com.aliucord.manager.network.dto.Commit
import com.aliucord.manager.ui.component.LoadFailure
import kotlinx.coroutines.flow.Flow
@Composable
fun CommitList(
commits: Flow<PagingData<Commit>>
commits: Flow<PagingData<Commit>>,
onRetry: () -> Unit
) {
ElevatedCard(
modifier = Modifier.padding(bottom = 16.dp)
@ -48,27 +50,42 @@ fun CommitList(
LazyColumn(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(10.dp)
verticalArrangement = Arrangement.spacedBy(10.dp),
contentPadding = PaddingValues(bottom = 12.dp)
) {
if (lazyPagingItems.loadState.refresh == LoadState.Loading) {
item {
Text(
when (lazyPagingItems.loadState.refresh) {
LoadState.Loading -> item {
CircularProgressIndicator(
modifier = Modifier
.fillMaxWidth()
.wrapContentWidth(Alignment.CenterHorizontally),
text = stringResource(R.string.paging_initial_load)
.padding(vertical = 16.dp)
.wrapContentWidth(Alignment.CenterHorizontally)
)
}
is LoadState.Error -> item {
Box(
modifier = Modifier
.fillParentMaxSize()
.padding(bottom = 50.dp),
contentAlignment = Alignment.Center
) {
LoadFailure(onRetry = {
lazyPagingItems.retry()
onRetry()
})
}
}
is LoadState.NotLoading -> {
items(lazyPagingItems) { commit ->
if (commit != null) {
CommitItem(commit)
}
}
}
}
items(lazyPagingItems) { commitData ->
if (commitData == null) return@items
CommitItem(commitData)
}
if (lazyPagingItems.loadState.append == LoadState.Loading) {
item {
when (lazyPagingItems.loadState.append) {
LoadState.Loading -> item {
CircularProgressIndicator(
modifier = Modifier
.fillMaxWidth()
@ -76,6 +93,12 @@ fun CommitList(
.wrapContentWidth(Alignment.CenterHorizontally)
)
}
is LoadState.Error -> item {
LoadFailure(onRetry = {
lazyPagingItems.retry()
})
}
else -> {}
}
}
}

View file

@ -11,7 +11,8 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Link
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
@ -26,6 +27,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import com.aliucord.manager.R
import com.aliucord.manager.ui.component.LoadFailure
import com.aliucord.manager.ui.component.contributors
import com.aliucord.manager.ui.viewmodel.AboutViewModel
import org.koin.androidx.compose.getViewModel
@ -110,21 +112,31 @@ fun AboutScreen(
}
}
item {
Row(
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 16.dp)
) {
UserEntry("Vendicated", "the ven")
UserEntry("Juby210", "Fox")
UserEntry("rushii", "explod", "DiamondMiner88")
if (!viewModel.fetchError) {
item {
Row(
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(top = 16.dp, bottom = 20.dp)
) {
UserEntry("Vendicated", "the ven")
UserEntry("Juby210", "Fox")
UserEntry("rushii", "explod", "DiamondMiner88")
}
}
contributors(viewModel.contributors)
} else {
item {
Box(
modifier = Modifier.padding(top = 12.dp)
) {
LoadFailure(onRetry = { viewModel.fetchContributors() })
}
}
}
contributors(viewModel.contributors)
}
}
}

View file

@ -74,7 +74,8 @@ fun HomeScreen(
)
CommitList(
commits = viewModel.commits
commits = viewModel.commits,
onRetry = { viewModel.fetchSupportedVersion() }
)
}
}

View file

@ -1,20 +1,35 @@
package com.aliucord.manager.ui.viewmodel
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.aliucord.manager.domain.repository.GithubRepository
import com.aliucord.manager.network.dto.GithubUser
import com.aliucord.manager.network.utils.fold
import kotlinx.coroutines.launch
class AboutViewModel(
private val githubRepository: GithubRepository
) : ViewModel() {
val contributors = mutableStateListOf<GithubUser>()
var fetchError by mutableStateOf(false)
init {
fetchContributors()
}
fun fetchContributors() {
viewModelScope.launch {
contributors.addAll(githubRepository.getContributors())
githubRepository.getContributors().fold(
success = {
contributors.addAll(it)
fetchError = false
},
fail = { fetchError = true }
)
}
}
}

View file

@ -11,6 +11,7 @@ import androidx.paging.*
import com.aliucord.manager.domain.manager.PreferencesManager
import com.aliucord.manager.domain.repository.GithubRepository
import com.aliucord.manager.network.dto.Commit
import com.aliucord.manager.network.utils.fold
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@ -32,14 +33,18 @@ class HomeViewModel(
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Commit> {
val page = params.key ?: 0
val response = githubRepository.getCommits(page)
val prevKey = if (page > 0) page - 1 else null
val nextKey = if (response.isNotEmpty()) page + 1 else null
return githubRepository.getCommits(page).fold(
success = { commits ->
val prevKey = if (page > 0) page - 1 else null
val nextKey = if (commits.isNotEmpty()) page + 1 else null
return LoadResult.Page(
data = response,
prevKey = prevKey,
nextKey = nextKey
LoadResult.Page(
data = commits,
prevKey = prevKey,
nextKey = nextKey
)
},
fail = { LoadResult.Error(it) }
)
}
@ -51,14 +56,7 @@ class HomeViewModel(
init {
viewModelScope.launch(Dispatchers.IO) {
val version = githubRepository.getVersion()
supportedVersion = "${version.versionName} - " + when (version.versionCode[3]) {
'0' -> "Stable"
'1' -> "Beta"
'2' -> "Alpha"
else -> throw NoWhenBranchMatchedException()
}
_fetchSupportedVersion()
installedVersion = try {
@Suppress("DEPRECATION")
@ -69,6 +67,28 @@ class HomeViewModel(
}
}
private suspend fun _fetchSupportedVersion() {
val version = githubRepository.getVersion()
supportedVersion = version.fold(
success = {
"${it.versionName} - " + when (it.versionCode[3]) {
'0' -> "Stable"
'1' -> "Beta"
'2' -> "Alpha"
else -> throw NoWhenBranchMatchedException()
}
},
fail = { "Failed to retrieve version" }
)
}
fun fetchSupportedVersion() {
viewModelScope.launch(Dispatchers.IO) {
_fetchSupportedVersion()
}
}
fun launchAliucord() {
val launchIntent = packageManager.getLaunchIntentForPackage(preferences.packageName)

View file

@ -16,6 +16,7 @@ import com.aliucord.manager.domain.repository.GithubRepository
import com.aliucord.manager.installer.service.InstallService
import com.aliucord.manager.installer.util.ManifestPatcher
import com.aliucord.manager.installer.util.Signer
import com.aliucord.manager.network.utils.fold
import com.aliucord.manager.ui.screen.InstallData
import com.android.zipflinger.*
import kotlinx.coroutines.*
@ -147,10 +148,12 @@ class InstallViewModel(
}
// Fetch gh releases for Aliucord/Hermes
val latestHermesRelease = githubRepository
.getHermesReleases().reduce { current, new ->
if (Instant.parse(current.createdAt).isBefore(Instant.parse(new.createdAt))) new else current
}
val latestHermesRelease = githubRepository.getHermesReleases().fold(
success = { releases ->
releases.maxBy { Instant.parse(it.createdAt) }
},
fail = { throw it }
)
// Download the hermes-release.aar file to replace in the apk
val hermesLibrary = externalCacheDir.resolve("hermes-release-${latestHermesRelease.tagName}.aar").also { file ->
@ -177,14 +180,12 @@ class InstallViewModel(
}
// Fetch the gh releases for Aliucord/AliucordNative
val latestAliucordNativeRelease = githubRepository
.getAliucordNativeReleases().reduce { current, new ->
if (Instant.parse(current.createdAt).isBefore(Instant.parse(new.createdAt))) {
new
} else {
current
}
}
val latestAliucordNativeRelease = githubRepository.getAliucordNativeReleases().fold(
success = { releases ->
releases.maxBy { Instant.parse(it.createdAt) }
},
fail = { throw it }
)
// Download the Aliucord classes.dex file to add to the apk
val aliucordDexFile = externalCacheDir.resolve("classes-${latestAliucordNativeRelease.tagName}.dex").also { file ->

View file

@ -34,7 +34,6 @@
<string name="contributions">%o contributions</string>
<string name="theme_setting_description">Manager appearance</string>
<string name="no_plugins_installed">No plugins installed!</string>
<string name="paging_initial_load">Loading…</string>
<string name="contributor">Contributor</string>
<string name="apply">Apply</string>
<string name="confirm">Confirm</string>
@ -55,4 +54,6 @@
<string name="permissions_grant">Grant Permissions</string>
<string name="permissions_grant_body">In order for Aliucord Manager to work properly, you need to grant file permissions. Since shared data in stored in ~/Aliucord, permissions are required in order for manager to function.</string>
<string name="aliucord_description">A modification for the Discord Android App</string>
<string name="load_fail">Failed to load</string>
<string name="retry">Retry</string>
</resources>