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:
Rahul Patel
2026-05-19 23:32:08 +05:30
parent fa0e3c53d1
commit 0391adf82f
4 changed files with 227 additions and 36 deletions

View File

@@ -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) },

View File

@@ -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
)
}
}
}

View File

@@ -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(),

View File

@@ -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