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