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 71b3d7e1b..4acc0bb08 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
@@ -6,6 +6,10 @@
package com.aurora.store.compose.ui.details
import android.content.ActivityNotFoundException
+import androidx.compose.animation.AnimatedContent
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.togetherWith
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -30,6 +34,7 @@ import androidx.compose.material3.adaptive.navigation.rememberSupportingPaneScaf
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -111,51 +116,65 @@ fun AppDetailsScreen(
LaunchedEffect(key1 = packageName) { viewModel.fetchAppDetails(packageName) }
- when (state) {
- is AppState.Loading -> ScreenContentLoading(onNavigateUp = onNavigateUp)
+ AnimatedContent(
+ targetState = state,
+ contentKey = { stateKey(it, app) },
+ transitionSpec = { fadeIn() togetherWith fadeOut() },
+ label = "AppDetailsState"
+ ) { currentState ->
+ when {
+ currentState is AppState.Loading || app == null ->
+ ScreenContentLoading(onNavigateUp = onNavigateUp)
- is AppState.Error -> {
- ScreenContentError(
- onNavigateUp = onNavigateUp,
- message = (state as AppState.Error).message
- )
- }
+ currentState is AppState.Error ->
+ ScreenContentError(
+ onNavigateUp = onNavigateUp,
+ message = currentState.message
+ )
- else -> {
- ScreenContentApp(
- app = app!!,
- featuredReviews = featuredReviews,
- suggestions = suggestions,
- isFavorite = favorite,
- isAnonymous = viewModel.authProvider.isAnonymous,
- state = state,
- plexusScores = plexusScores,
- dataSafetyReport = dataSafetyReport,
- exodusReport = exodusReport,
- onNavigateUp = onNavigateUp,
- onNavigateToAppDetails = onNavigateToAppDetails,
- onDownload = { requestedApp -> viewModel.enqueueDownload(requestedApp) },
- onFavorite = { viewModel.toggleFavourite(app!!) },
- onCancelDownload = { viewModel.cancelDownload(app!!) },
- onUninstall = { AppInstaller.uninstall(context, packageName) },
- onOpen = {
- try {
- context.startActivity(
- PackageUtil.getLaunchIntent(context, packageName)
- )
- } catch (_: ActivityNotFoundException) {
- context.toast(context.getString(R.string.unable_to_open))
- }
- },
- onTestingSubscriptionChange = { subscribe ->
- viewModel.updateTestingProgramStatus(packageName, subscribe)
- },
- forceSinglePane = forceSinglePane
- )
+ else -> {
+ val loadedApp = app!!
+ ScreenContentApp(
+ app = loadedApp,
+ featuredReviews = featuredReviews,
+ suggestions = suggestions,
+ isFavorite = favorite,
+ isAnonymous = viewModel.authProvider.isAnonymous,
+ state = currentState,
+ plexusScores = plexusScores,
+ dataSafetyReport = dataSafetyReport,
+ exodusReport = exodusReport,
+ onNavigateUp = onNavigateUp,
+ onNavigateToAppDetails = onNavigateToAppDetails,
+ onDownload = { requestedApp -> viewModel.enqueueDownload(requestedApp) },
+ onFavorite = { viewModel.toggleFavourite(loadedApp) },
+ onCancelDownload = { viewModel.cancelDownload(loadedApp) },
+ onUninstall = { AppInstaller.uninstall(context, packageName) },
+ onOpen = {
+ try {
+ context.startActivity(
+ PackageUtil.getLaunchIntent(context, packageName)
+ )
+ } catch (_: ActivityNotFoundException) {
+ context.toast(context.getString(R.string.unable_to_open))
+ }
+ },
+ onTestingSubscriptionChange = { subscribe ->
+ viewModel.updateTestingProgramStatus(packageName, subscribe)
+ },
+ forceSinglePane = forceSinglePane
+ )
+ }
}
}
}
+private fun stateKey(state: AppState, app: App?): String = when {
+ state is AppState.Loading || app == null -> "loading"
+ state is AppState.Error -> "error"
+ else -> "content"
+}
+
/**
* Composable to show progress while fetching app details
*/
@@ -282,51 +301,70 @@ private fun ScreenContentApp(
@Composable
fun SetupActions() {
- when (state) {
- is AppState.Queued,
- is AppState.Purchasing,
- is AppState.Downloading -> {
- Actions(
- primaryActionDisplayName = stringResource(R.string.action_open),
- secondaryActionDisplayName = stringResource(R.string.action_cancel),
- isPrimaryActionEnabled = false,
- onSecondaryAction = onCancelDownload
- )
- }
-
- is AppState.Updatable -> {
- Actions(
- primaryActionDisplayName = stringResource(R.string.action_update),
- secondaryActionDisplayName = stringResource(R.string.action_uninstall),
- onPrimaryAction = ::onInstall,
- onSecondaryAction = onUninstall
- )
- }
-
- is AppState.Installed -> {
- Actions(
- primaryActionDisplayName = stringResource(R.string.action_open),
- secondaryActionDisplayName = stringResource(R.string.action_uninstall),
- onPrimaryAction = onOpen,
- onSecondaryAction = onUninstall,
- isPrimaryActionEnabled = PackageUtil
- .getLaunchIntent(context, app.packageName) != null
- )
- }
-
- else -> {
- val primaryActionName = if (state is AppState.Archived) {
- stringResource(R.string.action_unarchive)
- } else {
- if (app.isFree) stringResource(R.string.action_install) else app.price
+ AnimatedContent(
+ targetState = state,
+ contentKey = { it::class },
+ transitionSpec = { fadeIn() togetherWith fadeOut() },
+ label = "Actions"
+ ) { currentState ->
+ when (currentState) {
+ is AppState.Queued,
+ is AppState.Purchasing,
+ is AppState.Downloading -> {
+ Actions(
+ primaryActionDisplayName = stringResource(R.string.action_open),
+ secondaryActionDisplayName = stringResource(R.string.action_cancel),
+ isPrimaryActionEnabled = false,
+ onSecondaryAction = onCancelDownload
+ )
}
- Actions(
- primaryActionDisplayName = primaryActionName,
- secondaryActionDisplayName = stringResource(R.string.title_manual_download),
- onPrimaryAction = ::onInstall,
- onSecondaryAction = { showExtraPane(ExtraScreen.ManualDownload) }
- )
+ is AppState.Verifying,
+ is AppState.Installing -> {
+ Actions(
+ primaryActionDisplayName = stringResource(R.string.action_open),
+ secondaryActionDisplayName = stringResource(R.string.action_cancel),
+ isPrimaryActionEnabled = false,
+ isSecondaryActionEnabled = false
+ )
+ }
+
+ is AppState.Updatable -> {
+ Actions(
+ primaryActionDisplayName = stringResource(R.string.action_update),
+ secondaryActionDisplayName = stringResource(R.string.action_uninstall),
+ onPrimaryAction = ::onInstall,
+ onSecondaryAction = onUninstall
+ )
+ }
+
+ is AppState.Installed -> {
+ val canOpen = remember(app.packageName) {
+ PackageUtil.getLaunchIntent(context, app.packageName) != null
+ }
+ Actions(
+ primaryActionDisplayName = stringResource(R.string.action_open),
+ secondaryActionDisplayName = stringResource(R.string.action_uninstall),
+ onPrimaryAction = onOpen,
+ onSecondaryAction = onUninstall,
+ isPrimaryActionEnabled = canOpen
+ )
+ }
+
+ else -> {
+ val primaryActionName = if (currentState is AppState.Archived) {
+ stringResource(R.string.action_unarchive)
+ } else {
+ if (app.isFree) stringResource(R.string.action_install) else app.price
+ }
+
+ Actions(
+ primaryActionDisplayName = primaryActionName,
+ secondaryActionDisplayName = stringResource(R.string.title_manual_download),
+ onPrimaryAction = ::onInstall,
+ onSecondaryAction = { showExtraPane(ExtraScreen.ManualDownload) }
+ )
+ }
}
}
}
@@ -405,7 +443,7 @@ private fun ScreenContentApp(
Privacy(
report = exodusReport,
- onNavigateToDetailsExodus = if (exodusReport?.id != -1) {
+ onNavigateToDetailsExodus = if (exodusReport != null && exodusReport.id != -1) {
{ showExtraPane(ExtraScreen.Exodus) }
} else {
null
@@ -428,31 +466,33 @@ private fun ScreenContentApp(
TopAppBar(actions = { if (!shouldShowMenuOnMainPane) SetupMenu() })
}
) { paddingValues ->
- Column(
- modifier = Modifier
- .fillMaxSize()
- .padding(paddingValues)
- ) {
- Row(
- modifier = Modifier.padding(dimensionResource(R.dimen.margin_medium)),
- verticalAlignment = Alignment.CenterVertically
- ) {
- Icon(
- painter = painterResource(R.drawable.ic_suggestions),
- contentDescription = null
- )
- Header(title = stringResource(R.string.pref_ui_similar_apps))
- }
- LazyColumn(
+ if (suggestions.isNotEmpty()) {
+ Column(
modifier = Modifier
.fillMaxSize()
- .padding(vertical = dimensionResource(R.dimen.padding_medium))
+ .padding(paddingValues)
) {
- items(items = suggestions, key = { item -> item.id }) { app ->
- LargeAppListItem(
- app = app,
- onClick = { onNavigateToAppDetails(app.packageName) }
+ Row(
+ modifier = Modifier.padding(dimensionResource(R.dimen.margin_medium)),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ painter = painterResource(R.drawable.ic_suggestions),
+ contentDescription = null
)
+ Header(title = stringResource(R.string.pref_ui_similar_apps))
+ }
+ LazyColumn(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(vertical = dimensionResource(R.dimen.padding_medium))
+ ) {
+ items(items = suggestions, key = { item -> item.id }) { app ->
+ LargeAppListItem(
+ app = app,
+ onClick = { onNavigateToAppDetails(app.packageName) }
+ )
+ }
}
}
}
diff --git a/app/src/main/java/com/aurora/store/compose/ui/details/composable/Details.kt b/app/src/main/java/com/aurora/store/compose/ui/details/composable/Details.kt
index 965fcd19a..bfb4bf8a7 100644
--- a/app/src/main/java/com/aurora/store/compose/ui/details/composable/Details.kt
+++ b/app/src/main/java/com/aurora/store/compose/ui/details/composable/Details.kt
@@ -137,15 +137,20 @@ fun Details(
Text(
style = MaterialTheme.typography.bodySmall,
text = when (cState) {
- AppState.Installing::class,
AppState.Downloading::class -> {
"${Formatter.formatShortFileSize(context, speed)}/s" +
", " + CommonUtil.getETAString(context, timeRemaining)
}
+ AppState.Installing::class -> stringResource(R.string.action_installing)
+
AppState.Queued::class -> stringResource(R.string.status_queued)
- AppState.Purchasing::class -> stringResource(R.string.preparing_to_install)
+ AppState.Purchasing::class ->
+ stringResource(R.string.preparing_to_download)
+
+ AppState.Verifying::class ->
+ stringResource(R.string.verifying_downloads)
else -> {
stringResource(R.string.version, versionName, versionCode)
diff --git a/app/src/main/java/com/aurora/store/data/helper/DownloadHelper.kt b/app/src/main/java/com/aurora/store/data/helper/DownloadHelper.kt
index b0a9b7fb2..bd902e1e4 100644
--- a/app/src/main/java/com/aurora/store/data/helper/DownloadHelper.kt
+++ b/app/src/main/java/com/aurora/store/data/helper/DownloadHelper.kt
@@ -20,6 +20,7 @@ import com.aurora.store.util.PathUtil
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
@@ -48,6 +49,12 @@ class DownloadHelper @Inject constructor(
val pagedDownloads get() = downloadDao.pagedDownloads()
+ /**
+ * One-shot read of the current download record for [packageName], if any.
+ */
+ suspend fun getDownload(packageName: String): Download? =
+ downloadDao.downloads().first().find { it.packageName == packageName }
+
/**
* Removes failed download from the queue and starts observing for newly enqueued apps.
*/
diff --git a/app/src/main/java/com/aurora/store/data/model/State.kt b/app/src/main/java/com/aurora/store/data/model/State.kt
index 6451a5394..117d6498e 100644
--- a/app/src/main/java/com/aurora/store/data/model/State.kt
+++ b/app/src/main/java/com/aurora/store/data/model/State.kt
@@ -54,6 +54,7 @@ sealed class AppState {
data object Queued : AppState()
data object Purchasing : AppState()
+ data object Verifying : AppState()
data class Installing(val progress: Float) : AppState()
data class Error(val message: String?) : AppState()
data class Installed(val versionName: String, val versionCode: Long) : AppState()
@@ -65,15 +66,19 @@ sealed class AppState {
/**
* Whether there is some sort of ongoing process related to the app
*/
- fun inProgress(): Boolean =
- this is Downloading || this is Installing || this is Purchasing || this is Queued
+ fun inProgress(): Boolean = this is Downloading ||
+ this is Installing ||
+ this is Purchasing ||
+ this is Queued ||
+ this is Verifying
/**
- * Progress of the process related to the app; 0 otherwise
+ * Determinate progress (0..100) of the process related to the app; 0 when no
+ * determinate value is available (e.g. installing, where the indicator should
+ * be indeterminate).
*/
fun progress(): Float = when (this) {
is Downloading -> progress
- is Installing -> progress
else -> 0F
}
}
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 91cd1dd4a..5ec664f7d 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
@@ -33,6 +33,7 @@ import com.aurora.store.data.model.PlexusReport
import com.aurora.store.data.model.Report
import com.aurora.store.data.model.Scores
import com.aurora.store.data.providers.AuthProvider
+import com.aurora.store.data.room.download.Download
import com.aurora.store.data.room.favourite.Favourite
import com.aurora.store.data.room.favourite.FavouriteDao
import com.aurora.store.util.CertUtil
@@ -154,7 +155,12 @@ class AppDetailsViewModel @Inject constructor(
_app.value = appDetailsHelper.getAppByPackageName(packageName).copy(
isInstalled = PackageUtil.isInstalled(context, packageName)
)
- _state.value = defaultAppState
+ // Seed state from any in-flight download for this package so reopening
+ // the screen doesn't briefly flash the default install action while the
+ // download flow catches up.
+ _state.value = downloadHelper.getDownload(packageName)
+ ?.let { stateFromDownload(it) }
+ ?: defaultAppState
} catch (exception: Exception) {
Log.e(TAG, "Failed to fetch app details", exception)
_app.value = null
@@ -252,22 +258,32 @@ class AppDetailsViewModel @Inject constructor(
}.launchIn(viewModelScope)
download.filterNotNull().onEach {
- _state.value = when (it.status) {
- DownloadStatus.DOWNLOADING -> AppState.Downloading(
- it.progress.toFloat(),
- it.speed,
- it.timeRemaining
- )
-
- DownloadStatus.QUEUED -> AppState.Queued
-
- DownloadStatus.PURCHASING -> AppState.Purchasing
-
- else -> defaultAppState
- }
+ _state.value = stateFromDownload(it)
}.launchIn(viewModelScope)
}
+ // COMPLETED is bridged to Installing so the UI doesn't briefly fall back to the
+ // install action between download finishing and the installer's first event.
+ // A stale COMPLETED row after install actually finished is handled by the
+ // isInstalled check.
+ private fun stateFromDownload(download: Download): AppState = when (download.status) {
+ DownloadStatus.DOWNLOADING -> AppState.Downloading(
+ download.progress.toFloat(),
+ download.speed,
+ download.timeRemaining
+ )
+
+ DownloadStatus.QUEUED -> AppState.Queued
+
+ DownloadStatus.PURCHASING -> AppState.Purchasing
+
+ DownloadStatus.VERIFYING -> AppState.Verifying
+
+ DownloadStatus.COMPLETED -> if (isInstalled) defaultAppState else AppState.Installing(0F)
+
+ else -> defaultAppState
+ }
+
private fun fetchFeaturedReviews(packageName: String) {
viewModelScope.launch(Dispatchers.IO) {
try {
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index dc933a1e2..a9fbe77bb 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -514,6 +514,8 @@
Unsupported: Not functional
Unknown: Not checked yet
Preparing to install
+ Preparing to download
+ Verifying downloads
- Permission required
- Permissions required