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.menu.MenuItem
|
||||||
import com.aurora.store.compose.ui.details.navigation.ExtraScreen
|
import com.aurora.store.compose.ui.details.navigation.ExtraScreen
|
||||||
import com.aurora.store.compose.ui.dev.DevProfileScreen
|
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.installer.AppInstaller
|
||||||
import com.aurora.store.data.model.AppState
|
import com.aurora.store.data.model.AppState
|
||||||
import com.aurora.store.data.model.PermissionType
|
import com.aurora.store.data.model.PermissionType
|
||||||
@@ -124,10 +125,22 @@ fun AppDetailsScreen(
|
|||||||
val exodusReport by viewModel.exodusReport.collectAsStateWithLifecycle()
|
val exodusReport by viewModel.exodusReport.collectAsStateWithLifecycle()
|
||||||
val dataSafetyReport by viewModel.dataSafetyReport.collectAsStateWithLifecycle()
|
val dataSafetyReport by viewModel.dataSafetyReport.collectAsStateWithLifecycle()
|
||||||
val plexusScores by viewModel.plexusScores.collectAsStateWithLifecycle()
|
val plexusScores by viewModel.plexusScores.collectAsStateWithLifecycle()
|
||||||
|
val installError by viewModel.installError.collectAsStateWithLifecycle()
|
||||||
val suggestionsBundle by viewModel.suggestionsBundle.collectForced(initial = null)
|
val suggestionsBundle by viewModel.suggestionsBundle.collectForced(initial = null)
|
||||||
|
|
||||||
LaunchedEffect(key1 = packageName) { viewModel.fetchAppDetails(packageName) }
|
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(
|
AnimatedContent(
|
||||||
targetState = state,
|
targetState = state,
|
||||||
contentKey = { stateKey(it, app) },
|
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,10 +116,16 @@ class DownloadWorker @AssistedInject constructor(
|
|||||||
// Set work/service to foreground on < Android 12.0
|
// Set work/service to foreground on < Android 12.0
|
||||||
setForeground(getForegroundInfo())
|
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)
|
notifyStatus(DownloadStatus.PURCHASING)
|
||||||
download.fileList = download.fileList.ifEmpty {
|
try {
|
||||||
purchase(download.packageName, download.versionCode, download.offerType)
|
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
|
// Bail out if file list is empty after purchase
|
||||||
@@ -137,20 +143,24 @@ class DownloadWorker @AssistedInject constructor(
|
|||||||
|
|
||||||
// Check if shared libs are present, if yes, handle them first
|
// Check if shared libs are present, if yes, handle them first
|
||||||
if (download.sharedLibs.isNotEmpty()) {
|
if (download.sharedLibs.isNotEmpty()) {
|
||||||
download.sharedLibs.forEach {
|
try {
|
||||||
// Create shared lib download dir
|
download.sharedLibs.forEach {
|
||||||
PathUtil.getLibDownloadDir(
|
// Create shared lib download dir
|
||||||
context,
|
PathUtil.getLibDownloadDir(
|
||||||
download.packageName,
|
context,
|
||||||
download.versionCode,
|
download.packageName,
|
||||||
it.packageName
|
download.versionCode,
|
||||||
).mkdirs()
|
it.packageName
|
||||||
|
).mkdirs()
|
||||||
|
|
||||||
// Purchase shared lib if file list is empty
|
// Purchase shared lib if file list is empty
|
||||||
it.fileList = it.fileList.ifEmpty {
|
it.fileList = it.fileList.ifEmpty {
|
||||||
purchase(it.packageName, it.versionCode, 0)
|
purchase(it.packageName, it.versionCode, 0)
|
||||||
|
}
|
||||||
|
files.addAll(it.fileList)
|
||||||
}
|
}
|
||||||
files.addAll(it.fileList)
|
} catch (exception: Exception) {
|
||||||
|
return onFailure(exception)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
files.addAll(download.fileList)
|
files.addAll(download.fileList)
|
||||||
@@ -227,13 +237,17 @@ class DownloadWorker @AssistedInject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
else -> {
|
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(
|
AuroraApp.events.send(
|
||||||
InstallerEvent.Failed(
|
InstallerEvent.Failed(
|
||||||
packageName = download.packageName,
|
packageName = download.packageName,
|
||||||
error = exception.stackTraceToString(),
|
error = userMessage,
|
||||||
extra = exception.message
|
extra = exception.stackTraceToString()
|
||||||
?: context.getString(R.string.download_failed)
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -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
|
* Purchases the app to get the download URL of the required files
|
||||||
* @param packageName The packageName of the app
|
* @param packageName The packageName of the app
|
||||||
@@ -255,21 +285,16 @@ class DownloadWorker @AssistedInject constructor(
|
|||||||
* @return A list of purchased files
|
* @return A list of purchased files
|
||||||
*/
|
*/
|
||||||
private fun purchase(packageName: String, versionCode: Long, offerType: Int): List<PlayFile> {
|
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
|
||||||
// Android 9.0+ supports key rotation, so purchase with latest certificate's hash
|
return if (isPAndAbove && PackageUtil.isInstalled(context, download.packageName)) {
|
||||||
return if (isPAndAbove && PackageUtil.isInstalled(context, download.packageName)) {
|
purchaseHelper.purchase(
|
||||||
purchaseHelper.purchase(
|
packageName,
|
||||||
packageName,
|
versionCode,
|
||||||
versionCode,
|
offerType,
|
||||||
offerType,
|
CertUtil.getEncodedCertificateHashes(context, download.packageName).last()
|
||||||
CertUtil.getEncodedCertificateHashes(context, download.packageName).last()
|
)
|
||||||
)
|
} else {
|
||||||
} else {
|
purchaseHelper.purchase(packageName, versionCode, offerType)
|
||||||
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(
|
private suspend fun notifyStatus(
|
||||||
status: DownloadStatus,
|
status: DownloadStatus,
|
||||||
isProgress: Boolean = false,
|
isProgress: Boolean = false,
|
||||||
exception: Exception? = null
|
message: String? = null
|
||||||
) {
|
) {
|
||||||
// Update status in database
|
// Update status in database
|
||||||
download.status = status
|
download.status = status
|
||||||
@@ -423,7 +448,7 @@ class DownloadWorker @AssistedInject constructor(
|
|||||||
context,
|
context,
|
||||||
download,
|
download,
|
||||||
icon,
|
icon,
|
||||||
exception?.message
|
message
|
||||||
)
|
)
|
||||||
notificationManager.notify(
|
notificationManager.notify(
|
||||||
if (isProgress) NOTIFICATION_ID else download.packageName.hashCode(),
|
if (isProgress) NOTIFICATION_ID else download.packageName.hashCode(),
|
||||||
|
|||||||
@@ -111,6 +111,11 @@ class AppDetailsViewModel @Inject constructor(
|
|||||||
private val _favourite = MutableStateFlow(false)
|
private val _favourite = MutableStateFlow(false)
|
||||||
val favourite = _favourite.asStateFlow()
|
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 ->
|
private val download = combine(app, downloadHelper.downloadsList) { a, list ->
|
||||||
if (a?.packageName.isNullOrBlank()) return@combine null
|
if (a?.packageName.isNullOrBlank()) return@combine null
|
||||||
list.find { d -> d.packageName == a.packageName }
|
list.find { d -> d.packageName == a.packageName }
|
||||||
@@ -268,10 +273,17 @@ class AppDetailsViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun dismissInstallError() {
|
||||||
|
_installError.value = null
|
||||||
|
}
|
||||||
|
|
||||||
private fun observeAppState() {
|
private fun observeAppState() {
|
||||||
AuroraApp.events.installerEvent
|
AuroraApp.events.installerEvent
|
||||||
.filter { it.packageName == app.value?.packageName }
|
.filter { it.packageName == app.value?.packageName }
|
||||||
.onEach { event ->
|
.onEach { event ->
|
||||||
|
if (event is InstallerEvent.Failed) {
|
||||||
|
_installError.value = InstallError(event.error, event.extra)
|
||||||
|
}
|
||||||
_state.value = when {
|
_state.value = when {
|
||||||
event is InstallerEvent.Installing -> AppState.Installing(event.progress)
|
event is InstallerEvent.Installing -> AppState.Installing(event.progress)
|
||||||
else -> defaultAppState
|
else -> defaultAppState
|
||||||
|
|||||||
Reference in New Issue
Block a user