diff --git a/app/src/main/kotlin/com/aliucord/manager/di/HttpModule.kt b/app/src/main/kotlin/com/aliucord/manager/di/HttpModule.kt index 1f3fc8c..11e72c8 100644 --- a/app/src/main/kotlin/com/aliucord/manager/di/HttpModule.kt +++ b/app/src/main/kotlin/com/aliucord/manager/di/HttpModule.kt @@ -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) } diff --git a/app/src/main/kotlin/com/aliucord/manager/domain/repository/GithubRepository.kt b/app/src/main/kotlin/com/aliucord/manager/domain/repository/GithubRepository.kt index 0bff85b..6c90256 100644 --- a/app/src/main/kotlin/com/aliucord/manager/domain/repository/GithubRepository.kt +++ b/app/src/main/kotlin/com/aliucord/manager/domain/repository/GithubRepository.kt @@ -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() diff --git a/app/src/main/kotlin/com/aliucord/manager/network/service/GithubService.kt b/app/src/main/kotlin/com/aliucord/manager/network/service/GithubService.kt index 5c89e89..603fe8c 100644 --- a/app/src/main/kotlin/com/aliucord/manager/network/service/GithubService.kt +++ b/app/src/main/kotlin/com/aliucord/manager/network/service/GithubService.kt @@ -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 = withContext(Dispatchers.IO) { - httpClient.get(COMMITS_URL) { + suspend fun getCommits(page: Int): ApiResponse> = 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 = withContext(Dispatchers.IO) { + http.request { + url(DATA_URL) + } } - suspend fun getContributors(): List = withContext(Dispatchers.IO) { - httpClient.get(CONTRIBUTORS_URL).body() + suspend fun getContributors(): ApiResponse> = withContext(Dispatchers.IO) { + http.request { + url(CONTRIBUTORS_URL) + } } - suspend fun getHermesReleases(): List = withContext(Dispatchers.IO) { - httpClient.get(HERMES_RELEASES_URL).body() + suspend fun getHermesReleases(): ApiResponse> = withContext(Dispatchers.IO) { + http.request { + url(HERMES_RELEASES_URL) + } } - suspend fun getAliucordNativeReleases(): List = withContext(Dispatchers.IO) { - httpClient.get(ALIUCORD_NATIVE_RELEASES_URL).body() + suspend fun getAliucordNativeReleases(): ApiResponse> = 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" diff --git a/app/src/main/kotlin/com/aliucord/manager/network/service/HttpService.kt b/app/src/main/kotlin/com/aliucord/manager/network/service/HttpService.kt new file mode 100644 index 0000000..d654b21 --- /dev/null +++ b/app/src/main/kotlin/com/aliucord/manager/network/service/HttpService.kt @@ -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 request(builder: HttpRequestBuilder.() -> Unit = {}): ApiResponse { + var body: String? = null + + val response = try { + val response = http.request(builder) + + if (response.status.isSuccess()) { + body = response.bodyAsText() + + ApiResponse.Success(json.decodeFromString(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 + } +} diff --git a/app/src/main/kotlin/com/aliucord/manager/network/utils/ApiResponse.kt b/app/src/main/kotlin/com/aliucord/manager/network/utils/ApiResponse.kt new file mode 100644 index 0000000..3ef33c9 --- /dev/null +++ b/app/src/main/kotlin/com/aliucord/manager/network/utils/ApiResponse.kt @@ -0,0 +1,48 @@ +@file:Suppress("NOTHING_TO_INLINE") + +package com.aliucord.manager.network.utils + +import io.ktor.http.* + +sealed interface ApiResponse { + data class Success(val data: T) : ApiResponse + data class Error(val error: ApiError) : ApiResponse + data class Failure(val error: ApiFailure) : ApiResponse +} + +class ApiError(code: HttpStatusCode, body: String?) : Error("HTTP Code $code, Body: $body") + +class ApiFailure(error: Throwable, body: String?) : Error(body, error) + +inline fun ApiResponse.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 ApiResponse.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 ApiResponse.transform(block: (T) -> R): ApiResponse { + return if (this !is ApiResponse.Success) { + // Error and Failure do not use the generic value + this as ApiResponse + } else { + ApiResponse.Success(block(data)) + } +} diff --git a/app/src/main/kotlin/com/aliucord/manager/ui/component/LoadFailure.kt b/app/src/main/kotlin/com/aliucord/manager/ui/component/LoadFailure.kt new file mode 100644 index 0000000..d723dbc --- /dev/null +++ b/app/src/main/kotlin/com/aliucord/manager/ui/component/LoadFailure.kt @@ -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)) + } + } +} diff --git a/app/src/main/kotlin/com/aliucord/manager/ui/component/home/CommitList.kt b/app/src/main/kotlin/com/aliucord/manager/ui/component/home/CommitList.kt index 9111b3f..0e3d8a8 100644 --- a/app/src/main/kotlin/com/aliucord/manager/ui/component/home/CommitList.kt +++ b/app/src/main/kotlin/com/aliucord/manager/ui/component/home/CommitList.kt @@ -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> + commits: Flow>, + 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 -> {} } } } diff --git a/app/src/main/kotlin/com/aliucord/manager/ui/screen/AboutScreen.kt b/app/src/main/kotlin/com/aliucord/manager/ui/screen/AboutScreen.kt index 71a1c50..4b274d3 100644 --- a/app/src/main/kotlin/com/aliucord/manager/ui/screen/AboutScreen.kt +++ b/app/src/main/kotlin/com/aliucord/manager/ui/screen/AboutScreen.kt @@ -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) } } } diff --git a/app/src/main/kotlin/com/aliucord/manager/ui/screen/HomeScreen.kt b/app/src/main/kotlin/com/aliucord/manager/ui/screen/HomeScreen.kt index 1aee229..add37c2 100644 --- a/app/src/main/kotlin/com/aliucord/manager/ui/screen/HomeScreen.kt +++ b/app/src/main/kotlin/com/aliucord/manager/ui/screen/HomeScreen.kt @@ -74,7 +74,8 @@ fun HomeScreen( ) CommitList( - commits = viewModel.commits + commits = viewModel.commits, + onRetry = { viewModel.fetchSupportedVersion() } ) } } diff --git a/app/src/main/kotlin/com/aliucord/manager/ui/viewmodel/AboutViewModel.kt b/app/src/main/kotlin/com/aliucord/manager/ui/viewmodel/AboutViewModel.kt index 5c4f405..8231286 100644 --- a/app/src/main/kotlin/com/aliucord/manager/ui/viewmodel/AboutViewModel.kt +++ b/app/src/main/kotlin/com/aliucord/manager/ui/viewmodel/AboutViewModel.kt @@ -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() + 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 } + ) } } } diff --git a/app/src/main/kotlin/com/aliucord/manager/ui/viewmodel/HomeViewModel.kt b/app/src/main/kotlin/com/aliucord/manager/ui/viewmodel/HomeViewModel.kt index 8f4938d..8486e57 100644 --- a/app/src/main/kotlin/com/aliucord/manager/ui/viewmodel/HomeViewModel.kt +++ b/app/src/main/kotlin/com/aliucord/manager/ui/viewmodel/HomeViewModel.kt @@ -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): LoadResult { 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) diff --git a/app/src/main/kotlin/com/aliucord/manager/ui/viewmodel/InstallViewModel.kt b/app/src/main/kotlin/com/aliucord/manager/ui/viewmodel/InstallViewModel.kt index 5c72dd2..5f55081 100644 --- a/app/src/main/kotlin/com/aliucord/manager/ui/viewmodel/InstallViewModel.kt +++ b/app/src/main/kotlin/com/aliucord/manager/ui/viewmodel/InstallViewModel.kt @@ -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 -> diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c9571fb..153897f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -34,7 +34,6 @@ %o contributions Manager appearance No plugins installed! - Loading… Contributor Apply Confirm @@ -55,4 +54,6 @@ Grant Permissions 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. A modification for the Discord Android App + Failed to load + Retry