Improve presentation of install errors

we now show a dialog with collapsed technical error message instead of a snackbar and the "Installing" section of My apps also shows installation errors.
This commit is contained in:
Torsten Grote
2025-10-30 12:09:49 -03:00
parent b4b5da6c72
commit ee89b59bb0
8 changed files with 115 additions and 34 deletions

View File

@@ -162,7 +162,14 @@ class AppInstallManager @Inject constructor(
)
// continue depending on result, abort early if no approval was given
return when (preApprovalResult) {
is PreApprovalResult.Error -> InstallState.Error(preApprovalResult.errorMsg)
is PreApprovalResult.Error -> InstallState.Error(
msg = preApprovalResult.errorMsg,
name = appMetadata.name.getBestLocale(LocaleListCompat.getDefault()) ?: "Unknown",
versionName = version.versionName,
currentVersionName = currentVersionName,
lastUpdated = version.added,
iconDownloadRequest = iconDownloadRequest,
)
is PreApprovalResult.UserAborted -> InstallState.UserAborted
is PreApprovalResult.Success, PreApprovalResult.NotSupported -> {
apps.checkAndUpdateApp(appMetadata.packageName) {
@@ -205,7 +212,15 @@ class AppInstallManager @Inject constructor(
if (e is CancellationException) throw e
log.error(e) { "Error downloading ${version.file}" }
val msg = "Download failed: ${e::class.java.simpleName} ${e.message}"
return InstallState.Error(msg)
return InstallState.Error(
msg = msg,
name = appMetadata.name.getBestLocale(LocaleListCompat.getDefault())
?: "Unknown",
versionName = version.versionName,
currentVersionName = currentVersionName,
lastUpdated = version.added,
iconDownloadRequest = iconDownloadRequest,
)
}
coroutineContext.ensureActive()
val newState = apps.checkAndUpdateApp(appMetadata.packageName) {

View File

@@ -79,7 +79,24 @@ sealed class InstallState(val showProgress: Boolean) {
) : InstallStateWithInfo(false)
data object UserAborted : InstallState(false)
data class Error(val msg: String?) : InstallState(false)
data class Error(
val msg: String?,
override val name: String,
override val versionName: String,
override val currentVersionName: String?,
override val lastUpdated: Long,
override val iconDownloadRequest: DownloadRequest?,
) : InstallStateWithInfo(false) {
constructor(msg: String?, s: InstallStateWithInfo) : this(
msg = msg,
name = s.name,
versionName = s.versionName,
currentVersionName = s.currentVersionName,
lastUpdated = s.lastUpdated,
iconDownloadRequest = s.iconDownloadRequest,
)
}
data object Uninstalled : InstallState(false)
}

View File

@@ -195,7 +195,8 @@ class SessionInstallManager @Inject constructor(
}
} catch (e: Exception) {
log.error(e) { "Error when creating session: " }
cont.resume(InstallState.Error("${e::class.java.simpleName} ${e.message}"))
val s = InstallState.Error("${e::class.java.simpleName} ${e.message}", state)
cont.resume(s)
return@suspendCancellableCoroutine
}
// set-up receiver for install result
@@ -248,7 +249,7 @@ class SessionInstallManager @Inject constructor(
)
cont.resume(newState)
} else {
cont.resume(InstallState.Error(msg))
cont.resume(InstallState.Error(msg, state))
}
}
}
@@ -284,7 +285,7 @@ class SessionInstallManager @Inject constructor(
}
} catch (e: Exception) {
log.error(e) { "Error during install session: " }
cont.resume(InstallState.Error("${e::class.java.simpleName} ${e.message}"))
cont.resume(InstallState.Error("${e::class.java.simpleName} ${e.message}", state))
}
}
@@ -311,7 +312,7 @@ class SessionInstallManager @Inject constructor(
if (status == PackageInstaller.STATUS_FAILURE_ABORTED) {
cont.resume(InstallState.UserAborted)
} else {
cont.resume(InstallState.Error(msg))
cont.resume(InstallState.Error(msg, installState))
}
}
}

View File

@@ -1,9 +1,11 @@
package org.fdroid.ui.apps
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.ErrorOutline
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
@@ -56,6 +58,15 @@ fun InstallingAppRow(
imageVector = Icons.Default.CheckCircle,
contentDescription = stringResource(R.string.app_installed),
tint = MaterialTheme.colorScheme.secondary,
modifier = Modifier.padding(8.dp)
)
} else if (app.installState is InstallState.Error) {
val desc = stringResource(R.string.notification_title_summary_install_error)
Icon(
imageVector = Icons.Default.ErrorOutline,
contentDescription = desc,
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(8.dp)
)
} else {
if (app.installState is InstallState.Downloading) {
@@ -103,10 +114,22 @@ private fun Preview() {
iconDownloadRequest = null,
)
)
val installingApp3 = InstallingAppItem(
packageName = "A3",
installState = InstallState.Error(
msg = "error msg",
name = "Installing App 2",
versionName = "0.0.4",
currentVersionName = null,
lastUpdated = 13,
iconDownloadRequest = null,
)
)
FDroidContent {
Column {
InstallingAppRow(installingApp1, false)
InstallingAppRow(installingApp2, true)
InstallingAppRow(installingApp3, false)
}
}
}

View File

@@ -14,41 +14,39 @@ import java.util.Locale
@Composable
fun MyAppsPresenter(
appInstallStatesFlow: StateFlow<Map<String, InstallState>>,
appUpdatesFlow: StateFlow<List<AppUpdateItem>?>,
appInstallStatesFlow: StateFlow<Map<String, InstallState>>,
installedAppsFlow: StateFlow<List<InstalledAppItem>?>,
searchQueryFlow: StateFlow<String>,
sortOrderFlow: StateFlow<AppListSortOrder>,
): MyAppsModel {
val appInstallStates = appInstallStatesFlow.collectAsState().value
val appUpdates = appUpdatesFlow.collectAsState().value
val appInstallStates = appInstallStatesFlow.collectAsState().value
val installedApps = installedAppsFlow.collectAsState().value
val searchQuery = searchQueryFlow.collectAsState().value.normalize()
val sortOrder = sortOrderFlow.collectAsState().value
val processedPackageNames = mutableSetOf<String>()
// TODO process updates first
val updates = appUpdates?.filter {
val keep = searchQuery.isBlank() ||
it.name.normalize().contains(searchQuery, ignoreCase = true)
if (keep) processedPackageNames.add(it.packageName)
keep
}
val installingApps = appInstallStates.mapNotNull { (packageName, state) ->
if (state is InstallStateWithInfo) {
val keep = if (searchQuery.isBlank()) {
packageName !in processedPackageNames
} else {
packageName !in processedPackageNames &&
state.name.normalize().contains(searchQuery, ignoreCase = true)
}
processedPackageNames.add(packageName)
InstallingAppItem(packageName, state)
if (keep) InstallingAppItem(packageName, state) else null
} else {
null
}
}
val installing = if (searchQuery.isBlank()) installingApps else installingApps.filter {
it.name.normalize().contains(searchQuery, ignoreCase = true)
}
val updates = appUpdates?.filter {
val keep = if (searchQuery.isBlank()) {
it.packageName !in processedPackageNames
} else {
it.packageName !in processedPackageNames &&
it.name.normalize().contains(searchQuery, ignoreCase = true)
}
processedPackageNames.add(it.packageName)
keep
}
val installed = installedApps?.filter {
if (searchQuery.isBlank()) {
it.packageName !in processedPackageNames
@@ -58,7 +56,7 @@ fun MyAppsPresenter(
}
}
return MyAppsModel(
installingApps = installing.sort(sortOrder),
installingApps = installingApps.sort(sortOrder),
appUpdates = updates?.sort(sortOrder),
installedApps = installed?.sort(sortOrder),
sortOrder = sortOrder,

View File

@@ -68,8 +68,8 @@ class MyAppsViewModel @Inject constructor(
private val sortOrder = savedStateHandle.getMutableStateFlow("sort", AppListSortOrder.NAME)
val myAppsModel: StateFlow<MyAppsModel> = moleculeScope.launchMolecule(mode = ContextClock) {
MyAppsPresenter(
appInstallStatesFlow = appInstallManager.appInstallStates,
appUpdatesFlow = updates,
appInstallStatesFlow = appInstallManager.appInstallStates,
installedAppsFlow = installedApps,
searchQueryFlow = searchQuery,
sortOrderFlow = sortOrder,

View File

@@ -22,6 +22,7 @@ import androidx.compose.material.icons.filled.Mail
import androidx.compose.material.icons.filled.OndemandVideo
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Translate
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ElevatedCard
@@ -29,8 +30,6 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBarDefaults
@@ -45,11 +44,11 @@ import androidx.compose.ui.Alignment.Companion.CenterHorizontally
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.fromHtml
import androidx.compose.ui.text.style.TextAlign.Companion.Center
import androidx.compose.ui.text.style.TextOverflow
@@ -79,9 +78,8 @@ fun AppDetails(
onBackNav: (() -> Unit)?,
modifier: Modifier = Modifier,
) {
val res = LocalResources.current
val topAppBarState = rememberTopAppBarState()
val snackbarHostState = remember { SnackbarHostState() }
var showInstallError by remember { mutableStateOf(false) }
val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior(topAppBarState)
if (item == null) BigLoadingIndicator()
else Scaffold(
@@ -89,7 +87,6 @@ fun AppDetails(
topBar = {
AppDetailsTopAppBar(item, topAppBarState, scrollBehavior, onBackNav)
},
snackbarHost = { SnackbarHost(snackbarHostState) },
) { innerPadding ->
// react to install state changes
LaunchedEffect(item.installState) {
@@ -98,8 +95,7 @@ fun AppDetails(
Log.i("AppDetails", "Requesting user confirmation... $state")
item.actions.requestUserConfirmation(item.app.packageName, state)
} else if (state is InstallState.Error) {
val msg = res.getString(R.string.install_error_notify_title, state.msg ?: "")
snackbarHostState.showSnackbar(msg)
showInstallError = true
}
}
val scrollState = rememberScrollState()
@@ -376,6 +372,36 @@ fun AppDetails(
}
}
}
if (showInstallError && item != null && item.installState is InstallState.Error) AlertDialog(
onDismissRequest = { showInstallError = false },
containerColor = MaterialTheme.colorScheme.errorContainer,
title = {
Text(stringResource(R.string.install_error_notify_title, item.name))
},
text = {
if (item.installState.msg == null) {
Text(stringResource(R.string.app_details_install_error_text))
} else {
ExpandableSection(
icon = null,
title = stringResource(R.string.app_details_install_error_text)
) {
SelectionContainer {
Text(
text = item.installState.msg,
fontFamily = FontFamily.Monospace,
modifier = Modifier.padding(top = 8.dp),
)
}
}
}
},
confirmButton = {
TextButton(onClick = { showInstallError = false }) {
Text(stringResource(R.string.ok))
}
},
)
}
@Preview

View File

@@ -65,6 +65,7 @@
<string name="architectures_colon">Architectures: %1$s</string>
<string name="app_details_incompatible_version">Incompatible • Installation likely to fail</string>
<string name="app_details_incompatible_signer">Signature mismatch, can\'t install</string>
<string name="app_details_install_error_text">Sorry! There was an unexpected error installing this app.</string>
<string name="notification_title">Notifications</string>
<string name="notification_summary">Opens system notification settings</string>