From 0391adf82fe215aa746a71fc7689ccd8471d0272 Mon Sep 17 00:00:00 2001 From: Rahul Patel Date: Tue, 19 May 2026 23:32:08 +0530 Subject: [PATCH] appdetails: add back error sheet - I intend to use this sheet to offer possible workarounds/solutions later. - Just future proofing --- .../compose/ui/details/AppDetailsScreen.kt | 13 ++ .../compose/ui/sheets/InstallErrorSheet.kt | 141 ++++++++++++++++++ .../aurora/store/data/work/DownloadWorker.kt | 97 +++++++----- .../viewmodel/details/AppDetailsViewModel.kt | 12 ++ 4 files changed, 227 insertions(+), 36 deletions(-) create mode 100644 app/src/main/java/com/aurora/store/compose/ui/sheets/InstallErrorSheet.kt diff --git a/app/src/main/java/com/aurora/store/compose/ui/details/AppDetailsScreen.kt b/app/src/main/java/com/aurora/store/compose/ui/details/AppDetailsScreen.kt index 7d7bc77d5..b0bd2c83b 100644 --- a/app/src/main/java/com/aurora/store/compose/ui/details/AppDetailsScreen.kt +++ b/app/src/main/java/com/aurora/store/compose/ui/details/AppDetailsScreen.kt @@ -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) }, diff --git a/app/src/main/java/com/aurora/store/compose/ui/sheets/InstallErrorSheet.kt b/app/src/main/java/com/aurora/store/compose/ui/sheets/InstallErrorSheet.kt new file mode 100644 index 000000000..34a4dd87f --- /dev/null +++ b/app/src/main/java/com/aurora/store/compose/ui/sheets/InstallErrorSheet.kt @@ -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 + ) + } + } +} diff --git a/app/src/main/java/com/aurora/store/data/work/DownloadWorker.kt b/app/src/main/java/com/aurora/store/data/work/DownloadWorker.kt index eec1c0775..1a22899d9 100644 --- a/app/src/main/java/com/aurora/store/data/work/DownloadWorker.kt +++ b/app/src/main/java/com/aurora/store/data/work/DownloadWorker.kt @@ -116,10 +116,16 @@ 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) - download.fileList = download.fileList.ifEmpty { - purchase(download.packageName, download.versionCode, download.offerType) + 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 @@ -137,20 +143,24 @@ class DownloadWorker @AssistedInject constructor( // Check if shared libs are present, if yes, handle them first if (download.sharedLibs.isNotEmpty()) { - download.sharedLibs.forEach { - // Create shared lib download dir - PathUtil.getLibDownloadDir( - context, - download.packageName, - download.versionCode, - it.packageName - ).mkdirs() + try { + download.sharedLibs.forEach { + // Create shared lib download dir + PathUtil.getLibDownloadDir( + context, + download.packageName, + download.versionCode, + it.packageName + ).mkdirs() - // Purchase shared lib if file list is empty - it.fileList = it.fileList.ifEmpty { - purchase(it.packageName, it.versionCode, 0) + // Purchase shared lib if file list is empty + it.fileList = it.fileList.ifEmpty { + purchase(it.packageName, it.versionCode, 0) + } + files.addAll(it.fileList) } - 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,21 +285,16 @@ class DownloadWorker @AssistedInject constructor( * @return A list of purchased files */ private fun purchase(packageName: String, versionCode: Long, offerType: Int): List { - try { - // Android 9.0+ supports key rotation, so purchase with latest certificate's hash - return if (isPAndAbove && PackageUtil.isInstalled(context, download.packageName)) { - purchaseHelper.purchase( - packageName, - versionCode, - offerType, - CertUtil.getEncodedCertificateHashes(context, download.packageName).last() - ) - } else { - purchaseHelper.purchase(packageName, versionCode, offerType) - } - } catch (exception: Exception) { - Log.e(TAG, "Failed to purchase $packageName", exception) - return emptyList() + // Android 9.0+ supports key rotation, so purchase with latest certificate's hash + return if (isPAndAbove && PackageUtil.isInstalled(context, download.packageName)) { + purchaseHelper.purchase( + packageName, + versionCode, + offerType, + CertUtil.getEncodedCertificateHashes(context, download.packageName).last() + ) + } else { + purchaseHelper.purchase(packageName, versionCode, offerType) } } @@ -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(), diff --git a/app/src/main/java/com/aurora/store/viewmodel/details/AppDetailsViewModel.kt b/app/src/main/java/com/aurora/store/viewmodel/details/AppDetailsViewModel.kt index a02c038f0..e01e362ca 100644 --- a/app/src/main/java/com/aurora/store/viewmodel/details/AppDetailsViewModel.kt +++ b/app/src/main/java/com/aurora/store/viewmodel/details/AppDetailsViewModel.kt @@ -111,6 +111,11 @@ class AppDetailsViewModel @Inject constructor( private val _favourite = MutableStateFlow(false) val favourite = _favourite.asStateFlow() + private val _installError = MutableStateFlow(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