From cb682f1f0bad340cada58e2b61b79596691944c3 Mon Sep 17 00:00:00 2001 From: Rahul Patel Date: Sat, 30 May 2026 23:17:41 +0530 Subject: [PATCH] Add one-click install for favourite apps Addresses #1508 Add an 'Install all' action to the Favourites screen that bulk-fetches details and enqueues every installable favourite (skipping already installed apps and paid apps anonymous accounts can't acquire), gated behind a confirmation dialog that warns about per-app install prompts. Show live state on each favourite row: an installed tick that updates on install/uninstall events, and download/install progress mirroring the Updates screen. Hide 'Install all' once every favourite is installed. Treat only non-finished download statuses as in progress on both the Favourites and Updates rows so a dismissed system install prompt no longer leaves a row stuck showing 'Installing'. --- .../compose/composable/FavouriteListItem.kt | 102 ++++++++++++++---- .../compose/composable/app/AppUpdateItem.kt | 8 +- .../ui/commons/InstallFavouritesDialog.kt | 64 +++++++++++ .../compose/ui/favourite/FavouriteScreen.kt | 71 ++++++++++++ .../store/viewmodel/all/FavouriteViewModel.kt | 71 ++++++++++++ app/src/main/res/values/strings.xml | 11 ++ 6 files changed, 305 insertions(+), 22 deletions(-) create mode 100644 app/src/main/java/com/aurora/store/compose/ui/commons/InstallFavouritesDialog.kt diff --git a/app/src/main/java/com/aurora/store/compose/composable/FavouriteListItem.kt b/app/src/main/java/com/aurora/store/compose/composable/FavouriteListItem.kt index d7475849a..0e5399077 100644 --- a/app/src/main/java/com/aurora/store/compose/composable/FavouriteListItem.kt +++ b/app/src/main/java/com/aurora/store/compose/composable/FavouriteListItem.kt @@ -14,7 +14,11 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -30,49 +34,94 @@ import coil3.compose.AsyncImage import coil3.request.ImageRequest import coil3.request.crossfade import com.aurora.gplayapi.data.models.App +import com.aurora.store.AuroraApp import com.aurora.store.R +import com.aurora.store.compose.composable.app.AnimatedAppIcon import com.aurora.store.compose.preview.AppPreviewProvider import com.aurora.store.compose.preview.ThemePreviewProvider import com.aurora.store.compose.theme.colorGreen import com.aurora.store.compose.theme.colorRed +import com.aurora.store.data.event.InstallerEvent +import com.aurora.store.data.model.DownloadStatus +import com.aurora.store.data.room.download.Download import com.aurora.store.data.room.favourite.Favourite import com.aurora.store.util.PackageUtil +import kotlinx.coroutines.flow.filter @Composable fun FavouriteListItem( modifier: Modifier = Modifier, favourite: Favourite, + download: Download? = null, onClick: () -> Unit = {}, onClear: () -> Unit = {} ) { val context = LocalContext.current - val isInstalled = remember(favourite.packageName) { - PackageUtil.isInstalled(context, favourite.packageName) + + // Seed from a one-shot check, then keep it live so the installed tick reflects the app + // being installed or removed without leaving the screen. + var isInstalled by remember(favourite.packageName) { + mutableStateOf(PackageUtil.isInstalled(context, favourite.packageName)) } + LaunchedEffect(favourite.packageName) { + AuroraApp.events.installerEvent + .filter { it.packageName == favourite.packageName } + .collect { event -> + when (event) { + is InstallerEvent.Installed -> isInstalled = true + is InstallerEvent.Uninstalled -> isInstalled = false + else -> {} + } + } + } + + // Show download/install progress on the row; only non-finished statuses count as in + // progress, so a downloaded-but-not-installed (COMPLETED) app stays in its idle state. + val inProgress = download != null && !download.isFinished + val progress = if (download?.status == DownloadStatus.DOWNLOADING) { + download.progress.toFloat() + } else { + 0f + } + + val statusText = when { + download?.status == DownloadStatus.DOWNLOADING -> + "${stringResource(R.string.status_downloading)} • ${download.progress}%" + + inProgress && download != null -> stringResource(download.status.localized) + else -> DateUtils.formatDateTime(context, favourite.added, DateUtils.FORMAT_SHOW_DATE) + } + RemovableListItem(onRemove = onClear) { triggerRemove -> AuroraListItem( modifier = modifier, headline = favourite.displayName, supporting = favourite.packageName, - tertiary = DateUtils.formatDateTime( - context, - favourite.added, - DateUtils.FORMAT_SHOW_DATE - ), + tertiary = statusText, headlineStyle = MaterialTheme.typography.bodyMedium, onClick = onClick, leading = { - AsyncImage( - model = ImageRequest.Builder(context) - .data(favourite.iconURL) - .crossfade(true) - .build(), - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier - .requiredSize(dimensionResource(R.dimen.icon_size_medium)) - .clip(RoundedCornerShape(dimensionResource(R.dimen.radius_medium))) - ) + if (inProgress) { + AnimatedAppIcon( + modifier = Modifier + .requiredSize(dimensionResource(R.dimen.icon_size_medium)), + iconUrl = favourite.iconURL, + inProgress = true, + progress = progress + ) + } else { + AsyncImage( + model = ImageRequest.Builder(context) + .data(favourite.iconURL) + .crossfade(true) + .build(), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .requiredSize(dimensionResource(R.dimen.icon_size_medium)) + .clip(RoundedCornerShape(dimensionResource(R.dimen.radius_medium))) + ) + } }, trailing = { Row( @@ -81,7 +130,7 @@ fun FavouriteListItem( dimensionResource(R.dimen.spacing_xsmall) ) ) { - if (isInstalled) { + if (isInstalled && !inProgress) { Icon( painter = painterResource(R.drawable.ic_check), contentDescription = stringResource(R.string.title_installed), @@ -107,3 +156,18 @@ fun FavouriteListItem( private fun FavouriteListItemPreview(@PreviewParameter(AppPreviewProvider::class) app: App) { FavouriteListItem(favourite = Favourite.fromApp(app, Favourite.Mode.MANUAL)) } + +@PreviewWrapper(ThemePreviewProvider::class) +@Preview(showBackground = true) +@Composable +private fun FavouriteListItemDownloadingPreview( + @PreviewParameter(AppPreviewProvider::class) app: App +) { + FavouriteListItem( + favourite = Favourite.fromApp(app, Favourite.Mode.MANUAL), + download = Download.fromApp(app).copy( + status = DownloadStatus.DOWNLOADING, + progress = 45 + ) + ) +} diff --git a/app/src/main/java/com/aurora/store/compose/composable/app/AppUpdateItem.kt b/app/src/main/java/com/aurora/store/compose/composable/app/AppUpdateItem.kt index 4316054fe..685e4605d 100644 --- a/app/src/main/java/com/aurora/store/compose/composable/app/AppUpdateItem.kt +++ b/app/src/main/java/com/aurora/store/compose/composable/app/AppUpdateItem.kt @@ -43,8 +43,10 @@ fun AppUpdateItem( onCancel: () -> Unit = {}, onUnignore: (() -> Unit)? = null ) { + // Only the INSTALLING status shows as "Installing"; a downloaded-but-not-installed + // (COMPLETED) app falls back to the "Update" action. val inProgress = download != null && !download.isFinished - val pendingInstall = download?.status == DownloadStatus.COMPLETED + val installing = download?.status == DownloadStatus.INSTALLING val progress = if (download?.status == DownloadStatus.DOWNLOADING) { download.progress.toFloat() } else { @@ -65,7 +67,7 @@ fun AppUpdateItem( AnimatedAppIcon( modifier = Modifier.requiredSize(dimensionResource(R.dimen.icon_size_medium)), iconUrl = update.iconURL, - inProgress = inProgress || pendingInstall, + inProgress = inProgress, progress = progress ) } @@ -100,7 +102,7 @@ fun AppUpdateItem( } } - pendingInstall -> { + installing -> { OutlinedButton(onClick = {}, enabled = false) { Text(stringResource(R.string.action_installing)) } diff --git a/app/src/main/java/com/aurora/store/compose/ui/commons/InstallFavouritesDialog.kt b/app/src/main/java/com/aurora/store/compose/ui/commons/InstallFavouritesDialog.kt new file mode 100644 index 000000000..2873a29da --- /dev/null +++ b/app/src/main/java/com/aurora/store/compose/ui/commons/InstallFavouritesDialog.kt @@ -0,0 +1,64 @@ +/* + * SPDX-FileCopyrightText: 2026 Aurora OSS + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.aurora.store.compose.ui.commons + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewWrapper +import com.aurora.store.R +import com.aurora.store.compose.preview.ThemePreviewProvider + +/** + * Confirmation dialog for bulk-installing all favourite apps. + * @param count Number of favourites that will be processed + * @param onConfirm Callback on confirmation + * @param onDismiss Callback on dismissal + */ +@Composable +fun InstallFavouritesDialog( + count: Int = 0, + onConfirm: () -> Unit = {}, + onDismiss: () -> Unit = {} +) { + AlertDialog( + title = { Text(text = stringResource(R.string.title_install_favourites)) }, + text = { + Column( + verticalArrangement = Arrangement.spacedBy( + dimensionResource(R.dimen.spacing_small) + ) + ) { + Text(text = stringResource(R.string.install_favourites_summary, count)) + Text(text = stringResource(R.string.install_favourites_warning)) + } + }, + onDismissRequest = onDismiss, + confirmButton = { + TextButton(onClick = onConfirm) { + Text(text = stringResource(R.string.action_install)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(text = stringResource(R.string.action_cancel)) + } + } + ) +} + +@PreviewWrapper(ThemePreviewProvider::class) +@Preview +@Composable +private fun InstallFavouritesDialogPreview() { + InstallFavouritesDialog(count = 5) +} diff --git a/app/src/main/java/com/aurora/store/compose/ui/favourite/FavouriteScreen.kt b/app/src/main/java/com/aurora/store/compose/ui/favourite/FavouriteScreen.kt index e59ab371c..e8494929e 100644 --- a/app/src/main/java/com/aurora/store/compose/ui/favourite/FavouriteScreen.kt +++ b/app/src/main/java/com/aurora/store/compose/ui/favourite/FavouriteScreen.kt @@ -15,9 +15,13 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -25,11 +29,13 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewWrapper import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.paging.LoadState import androidx.paging.PagingData import androidx.paging.compose.LazyPagingItems @@ -43,12 +49,15 @@ import com.aurora.store.compose.composable.ContainedLoadingIndicator import com.aurora.store.compose.composable.FavouriteListItem import com.aurora.store.compose.composable.Placeholder import com.aurora.store.compose.composable.ScrollHint +import com.aurora.store.compose.composable.SectionHeader import com.aurora.store.compose.composable.TopAppBar import com.aurora.store.compose.navigation.Destination import com.aurora.store.compose.preview.FavouritePreviewProvider import com.aurora.store.compose.preview.ThemePreviewProvider +import com.aurora.store.compose.ui.commons.InstallFavouritesDialog import com.aurora.store.compose.ui.favourite.menu.FavouriteMenu import com.aurora.store.compose.ui.favourite.menu.MenuItem +import com.aurora.store.data.room.download.Download import com.aurora.store.data.room.favourite.Favourite import com.aurora.store.viewmodel.all.FavouriteViewModel import java.util.Calendar @@ -62,6 +71,24 @@ fun FavouriteScreen( ) { val context = LocalContext.current val favourites = viewModel.favourites.collectAsLazyPagingItems() + val isEnqueuing by viewModel.isEnqueuing.collectAsStateWithLifecycle() + val hasInstallableFavourites by viewModel.hasInstallableFavourites + .collectAsStateWithLifecycle() + val downloads by viewModel.downloadsList.collectAsStateWithLifecycle() + val downloadMap = remember(downloads) { downloads.associateBy { it.packageName } } + + LaunchedEffect(Unit) { + viewModel.enqueueResult.collect { count -> + when { + count > 0 -> context.toast( + context.getString(R.string.toast_fav_install_enqueued, count) + ) + + count == 0 -> context.toast(R.string.toast_fav_install_none) + else -> context.toast(R.string.toast_fav_install_failed) + } + } + } val documentOpenLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.OpenDocument(), @@ -89,7 +116,11 @@ fun FavouriteScreen( ScreenContent( favourites = favourites, + downloads = downloadMap, + isEnqueuing = isEnqueuing, + showInstallAll = hasInstallableFavourites, onNavigateTo = onNavigateTo, + onInstallAll = { viewModel.installAll() }, onRemoveFavourite = { packageName -> viewModel.removeFavourite(packageName) }, onImportFavourites = { documentOpenLauncher.launch(arrayOf(JSON_MIME_TYPE)) @@ -105,7 +136,11 @@ fun FavouriteScreen( @Composable private fun ScreenContent( favourites: LazyPagingItems = emptyPagingItems(), + downloads: Map = emptyMap(), + isEnqueuing: Boolean = false, + showInstallAll: Boolean = true, onNavigateTo: (Destination) -> Unit = {}, + onInstallAll: () -> Unit = {}, onRemoveFavourite: (packageName: String) -> Unit = {}, onImportFavourites: () -> Unit = {}, onExportFavourites: () -> Unit = {} @@ -117,6 +152,18 @@ private fun ScreenContent( * Save the initial loading state to make sure we don't replay the loading animation again. */ var initialLoad by rememberSaveable { mutableStateOf(true) } + var showInstallDialog by remember { mutableStateOf(false) } + + if (showInstallDialog) { + InstallFavouritesDialog( + count = favourites.itemCount, + onConfirm = { + showInstallDialog = false + onInstallAll() + }, + onDismiss = { showInstallDialog = false } + ) + } @Composable fun SetupMenu() { @@ -162,6 +209,29 @@ private fun ScreenContent( LazyColumn( state = listState ) { + item(key = "header") { + SectionHeader( + title = pluralStringResource( + R.plurals.favourites_count, + favourites.itemCount, + favourites.itemCount + ), + trailing = if (showInstallAll) { + { + TextButton( + onClick = { showInstallDialog = true }, + enabled = !isEnqueuing + ) { + Text( + stringResource(R.string.action_install_all) + ) + } + } + } else { + null + } + ) + } items( count = favourites.itemCount, key = favourites.itemKey { it.packageName } @@ -170,6 +240,7 @@ private fun ScreenContent( FavouriteListItem( modifier = Modifier.animateItem(), favourite = favourite, + download = downloads[favourite.packageName], onClick = { onNavigateTo( Destination.AppDetails(favourite.packageName) diff --git a/app/src/main/java/com/aurora/store/viewmodel/all/FavouriteViewModel.kt b/app/src/main/java/com/aurora/store/viewmodel/all/FavouriteViewModel.kt index 0cd75c046..f9a966132 100644 --- a/app/src/main/java/com/aurora/store/viewmodel/all/FavouriteViewModel.kt +++ b/app/src/main/java/com/aurora/store/viewmodel/all/FavouriteViewModel.kt @@ -14,20 +14,35 @@ import androidx.lifecycle.viewModelScope import androidx.paging.PagingData import androidx.paging.cachedIn import com.aurora.extensions.TAG +import com.aurora.gplayapi.helpers.AppDetailsHelper +import com.aurora.store.AuroraApp +import com.aurora.store.data.event.InstallerEvent +import com.aurora.store.data.helper.DownloadHelper import com.aurora.store.data.paging.GenericPagingSource.Companion.pager +import com.aurora.store.data.providers.AuthProvider import com.aurora.store.data.room.favourite.Favourite import com.aurora.store.data.room.favourite.FavouriteDao import com.aurora.store.data.room.favourite.ImportExport +import com.aurora.store.util.PackageUtil import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.serialization.json.Json @@ -35,12 +50,40 @@ import kotlinx.serialization.json.Json class FavouriteViewModel @Inject constructor( private val favouriteDao: FavouriteDao, private val json: Json, + private val appDetailsHelper: AppDetailsHelper, + private val downloadHelper: DownloadHelper, + private val authProvider: AuthProvider, @ApplicationContext private val context: Context ) : ViewModel() { private val _favourites = MutableStateFlow>(PagingData.empty()) val favourites = _favourites.asStateFlow() + val downloadsList get() = downloadHelper.downloadsList + + private val _isEnqueuing = MutableStateFlow(false) + val isEnqueuing = _isEnqueuing.asStateFlow() + + // Emits the number of favourites actually enqueued for install (0 when nothing was + // applicable, -1 when fetching details failed) so the screen can show accurate feedback. + private val _enqueueResult = MutableSharedFlow() + val enqueueResult = _enqueueResult.asSharedFlow() + + // Whether at least one favourite is still not installed, recomputed whenever the favourites + // list changes or any app is installed/removed. Drives visibility of the "Install all" + // action so it hides once every favourite is already installed. Defaults to true to avoid + // briefly hiding the action before the first check completes. + val hasInstallableFavourites = combine( + favouriteDao.favourites(), + AuroraApp.events.installerEvent + .filter { it is InstallerEvent.Installed || it is InstallerEvent.Uninstalled } + .map { } + .onStart { emit(Unit) } + ) { favourites, _ -> + favourites.any { !PackageUtil.isInstalled(context, it.packageName) } + }.flowOn(Dispatchers.IO) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), true) + init { getPagedFavourites() } @@ -86,6 +129,34 @@ class FavouriteViewModel @Inject constructor( } } + /** + * Fetches details for all favourites and enqueues the installable ones for download & + * install. Already installed (and up-to-date) apps are skipped, as are paid apps that an + * anonymous account can't acquire. The actual count enqueued is emitted via [enqueueResult]. + */ + fun installAll() { + viewModelScope.launch(Dispatchers.IO) { + _isEnqueuing.value = true + try { + val packageNames = favouriteDao.favourites().first().map { it.packageName } + val installable = appDetailsHelper.getAppByPackageName(packageNames).filter { app -> + val needsInstall = !PackageUtil.isInstalled(context, app.packageName) || + PackageUtil.isUpdatable(context, app.packageName, app.versionCode) + val acquirable = app.isFree || !authProvider.isAnonymous + needsInstall && acquirable + } + + installable.forEach { downloadHelper.enqueueApp(it) } + _enqueueResult.emit(installable.size) + } catch (exception: Exception) { + Log.e(TAG, "Failed to enqueue favourites for install", exception) + _enqueueResult.emit(-1) + } finally { + _isEnqueuing.value = false + } + } + } + private fun getPagedFavourites() { pager { favouriteDao.pagedFavourites() }.flow .distinctUntilChanged() diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a3ffdaf7b..fe74bacc8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -77,6 +77,7 @@ "Granted" "Ignore" "Install" + "Install all" "Install microG Bundle" "Installations" "Installing" @@ -296,6 +297,9 @@ "Installation" "Installed" "App installer" + "Install favourites?" + "This will download and install %1$d apps." + "You may see a separate installation prompt for each app." "Language" "Library" "No network" @@ -508,6 +512,9 @@ Failed to export favourites! Favourites imported! Favourites exported! + "Installing %1$d apps" + "Nothing to install" + "Failed to fetch app details" Failed to import blacklist! @@ -586,6 +593,10 @@ Permission required Permissions required + + %1$d favourite + %1$d favourites + %d app installed %d apps installed