appdetails: add back error sheet
- I intend to use this sheet to offer possible workarounds/solutions later. - Just future proofing
This commit is contained in:
@@ -94,6 +94,7 @@ import com.aurora.store.compose.ui.details.menu.AppDetailsMenu
|
||||
import com.aurora.store.compose.ui.details.menu.MenuItem
|
||||
import com.aurora.store.compose.ui.details.navigation.ExtraScreen
|
||||
import com.aurora.store.compose.ui.dev.DevProfileScreen
|
||||
import com.aurora.store.compose.ui.sheets.InstallErrorSheet
|
||||
import com.aurora.store.data.installer.AppInstaller
|
||||
import com.aurora.store.data.model.AppState
|
||||
import com.aurora.store.data.model.PermissionType
|
||||
@@ -124,10 +125,22 @@ fun AppDetailsScreen(
|
||||
val exodusReport by viewModel.exodusReport.collectAsStateWithLifecycle()
|
||||
val dataSafetyReport by viewModel.dataSafetyReport.collectAsStateWithLifecycle()
|
||||
val plexusScores by viewModel.plexusScores.collectAsStateWithLifecycle()
|
||||
val installError by viewModel.installError.collectAsStateWithLifecycle()
|
||||
val suggestionsBundle by viewModel.suggestionsBundle.collectForced(initial = null)
|
||||
|
||||
LaunchedEffect(key1 = packageName) { viewModel.fetchAppDetails(packageName) }
|
||||
|
||||
app?.let { loadedApp ->
|
||||
installError?.let { err ->
|
||||
InstallErrorSheet(
|
||||
app = loadedApp,
|
||||
error = err.error,
|
||||
extra = err.extra,
|
||||
onDismiss = viewModel::dismissInstallError
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
AnimatedContent(
|
||||
targetState = state,
|
||||
contentKey = { stateKey(it, app) },
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Aurora OSS
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package com.aurora.store.compose.ui.sheets
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.requiredSize
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.dimensionResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import coil3.compose.AsyncImage
|
||||
import coil3.request.ImageRequest
|
||||
import coil3.request.crossfade
|
||||
import com.aurora.extensions.copyToClipBoard
|
||||
import com.aurora.extensions.toast
|
||||
import com.aurora.gplayapi.data.models.App
|
||||
import com.aurora.store.R
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun InstallErrorSheet(app: App, error: String?, extra: String?, onDismiss: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
Header(app = app)
|
||||
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(
|
||||
vertical = dimensionResource(R.dimen.spacing_xsmall)
|
||||
)
|
||||
)
|
||||
|
||||
if (!error.isNullOrBlank()) {
|
||||
Text(
|
||||
text = error,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.padding(
|
||||
horizontal = dimensionResource(R.dimen.spacing_medium),
|
||||
vertical = dimensionResource(R.dimen.spacing_xsmall)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(
|
||||
horizontal = dimensionResource(R.dimen.spacing_xsmall),
|
||||
vertical = dimensionResource(R.dimen.spacing_xsmall)
|
||||
),
|
||||
horizontalArrangement = Arrangement.End
|
||||
) {
|
||||
TextButton(
|
||||
onClick = {
|
||||
context.copyToClipBoard(listOfNotNull(error, extra).joinToString("\n\n"))
|
||||
context.toast(R.string.toast_clipboard_copied)
|
||||
}
|
||||
) {
|
||||
Text(text = stringResource(R.string.action_copy))
|
||||
}
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(text = stringResource(R.string.action_ok))
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.navigationBarsPadding())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Header(app: App) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(
|
||||
horizontal = dimensionResource(R.dimen.spacing_medium),
|
||||
vertical = dimensionResource(R.dimen.spacing_xsmall)
|
||||
),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.spacing_medium))
|
||||
) {
|
||||
AsyncImage(
|
||||
model = ImageRequest.Builder(LocalContext.current)
|
||||
.data(app.iconArtwork.url)
|
||||
.crossfade(true)
|
||||
.build(),
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.requiredSize(dimensionResource(R.dimen.icon_size_medium))
|
||||
.clip(RoundedCornerShape(dimensionResource(R.dimen.radius_medium)))
|
||||
)
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = stringResource(R.string.title_installer),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Text(
|
||||
text = app.displayName,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -116,11 +116,17 @@ class DownloadWorker @AssistedInject constructor(
|
||||
// Set work/service to foreground on < Android 12.0
|
||||
setForeground(getForegroundInfo())
|
||||
|
||||
// Try to purchase the app if file list is empty
|
||||
// Try to purchase the app if file list is empty. Surface any GPlayApi error
|
||||
// (e.g. AppNotPurchased, AppNotSupported, AppRemoved) as the failure cause so
|
||||
// the user sees the real reason instead of a generic "files not available".
|
||||
notifyStatus(DownloadStatus.PURCHASING)
|
||||
try {
|
||||
download.fileList = download.fileList.ifEmpty {
|
||||
purchase(download.packageName, download.versionCode, download.offerType)
|
||||
}
|
||||
} catch (exception: Exception) {
|
||||
return onFailure(exception)
|
||||
}
|
||||
|
||||
// Bail out if file list is empty after purchase
|
||||
if (download.fileList.isEmpty()) return onFailure(NothingToDownloadException())
|
||||
@@ -137,6 +143,7 @@ class DownloadWorker @AssistedInject constructor(
|
||||
|
||||
// Check if shared libs are present, if yes, handle them first
|
||||
if (download.sharedLibs.isNotEmpty()) {
|
||||
try {
|
||||
download.sharedLibs.forEach {
|
||||
// Create shared lib download dir
|
||||
PathUtil.getLibDownloadDir(
|
||||
@@ -152,6 +159,9 @@ class DownloadWorker @AssistedInject constructor(
|
||||
}
|
||||
files.addAll(it.fileList)
|
||||
}
|
||||
} catch (exception: Exception) {
|
||||
return onFailure(exception)
|
||||
}
|
||||
}
|
||||
files.addAll(download.fileList)
|
||||
|
||||
@@ -227,13 +237,17 @@ class DownloadWorker @AssistedInject constructor(
|
||||
}
|
||||
|
||||
else -> {
|
||||
notifyStatus(status = DownloadStatus.FAILED, exception = exception)
|
||||
val userMessage = exception.userReason()
|
||||
?: context.getString(R.string.download_failed)
|
||||
notifyStatus(
|
||||
status = DownloadStatus.FAILED,
|
||||
message = userMessage
|
||||
)
|
||||
AuroraApp.events.send(
|
||||
InstallerEvent.Failed(
|
||||
packageName = download.packageName,
|
||||
error = exception.stackTraceToString(),
|
||||
extra = exception.message
|
||||
?: context.getString(R.string.download_failed)
|
||||
error = userMessage,
|
||||
extra = exception.stackTraceToString()
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -247,6 +261,22 @@ class DownloadWorker @AssistedInject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts a user-readable message from an exception. Falls back to reading the
|
||||
* `reason` property via reflection for GPlayApi's `InternalException` variants,
|
||||
* which expose their detail through a data-class field rather than [Throwable.message].
|
||||
*
|
||||
* TODO: Remove reflection and use a proper exception hierarchy once GPlayApi supports it.
|
||||
*/
|
||||
private fun Throwable.userReason(): String? {
|
||||
message?.takeIf { it.isNotBlank() }?.let { return it }
|
||||
return runCatching {
|
||||
javaClass.getDeclaredField("reason")
|
||||
.apply { isAccessible = true }
|
||||
.get(this) as? String
|
||||
}.getOrNull()?.takeIf { it.isNotBlank() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Purchases the app to get the download URL of the required files
|
||||
* @param packageName The packageName of the app
|
||||
@@ -255,7 +285,6 @@ class DownloadWorker @AssistedInject constructor(
|
||||
* @return A list of purchased files
|
||||
*/
|
||||
private fun purchase(packageName: String, versionCode: Long, offerType: Int): List<PlayFile> {
|
||||
try {
|
||||
// Android 9.0+ supports key rotation, so purchase with latest certificate's hash
|
||||
return if (isPAndAbove && PackageUtil.isInstalled(context, download.packageName)) {
|
||||
purchaseHelper.purchase(
|
||||
@@ -267,10 +296,6 @@ class DownloadWorker @AssistedInject constructor(
|
||||
} else {
|
||||
purchaseHelper.purchase(packageName, versionCode, offerType)
|
||||
}
|
||||
} catch (exception: Exception) {
|
||||
Log.e(TAG, "Failed to purchase $packageName", exception)
|
||||
return emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -400,7 +425,7 @@ class DownloadWorker @AssistedInject constructor(
|
||||
private suspend fun notifyStatus(
|
||||
status: DownloadStatus,
|
||||
isProgress: Boolean = false,
|
||||
exception: Exception? = null
|
||||
message: String? = null
|
||||
) {
|
||||
// Update status in database
|
||||
download.status = status
|
||||
@@ -423,7 +448,7 @@ class DownloadWorker @AssistedInject constructor(
|
||||
context,
|
||||
download,
|
||||
icon,
|
||||
exception?.message
|
||||
message
|
||||
)
|
||||
notificationManager.notify(
|
||||
if (isProgress) NOTIFICATION_ID else download.packageName.hashCode(),
|
||||
|
||||
@@ -111,6 +111,11 @@ class AppDetailsViewModel @Inject constructor(
|
||||
private val _favourite = MutableStateFlow(false)
|
||||
val favourite = _favourite.asStateFlow()
|
||||
|
||||
private val _installError = MutableStateFlow<InstallError?>(null)
|
||||
val installError = _installError.asStateFlow()
|
||||
|
||||
data class InstallError(val error: String?, val extra: String?)
|
||||
|
||||
private val download = combine(app, downloadHelper.downloadsList) { a, list ->
|
||||
if (a?.packageName.isNullOrBlank()) return@combine null
|
||||
list.find { d -> d.packageName == a.packageName }
|
||||
@@ -268,10 +273,17 @@ class AppDetailsViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun dismissInstallError() {
|
||||
_installError.value = null
|
||||
}
|
||||
|
||||
private fun observeAppState() {
|
||||
AuroraApp.events.installerEvent
|
||||
.filter { it.packageName == app.value?.packageName }
|
||||
.onEach { event ->
|
||||
if (event is InstallerEvent.Failed) {
|
||||
_installError.value = InstallError(event.error, event.extra)
|
||||
}
|
||||
_state.value = when {
|
||||
event is InstallerEvent.Installing -> AppState.Installing(event.progress)
|
||||
else -> defaultAppState
|
||||
|
||||
Reference in New Issue
Block a user