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'.
This commit is contained in:
Rahul Patel
2026-05-30 23:17:41 +05:30
parent 2115ed8479
commit cb682f1f0b
6 changed files with 305 additions and 22 deletions

View File

@@ -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,38 +34,82 @@ 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 = {
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)
@@ -73,6 +121,7 @@ fun FavouriteListItem(
.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
)
)
}

View File

@@ -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))
}

View File

@@ -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)
}

View File

@@ -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<Favourite> = emptyPagingItems(),
downloads: Map<String, Download> = 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)

View File

@@ -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<Favourite>>(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<Int>()
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()

View File

@@ -77,6 +77,7 @@
<string name="action_granted">"Granted"</string>
<string name="action_ignore">"Ignore"</string>
<string name="action_install">"Install"</string>
<string name="action_install_all">"Install all"</string>
<string name="action_install_microG">"Install microG Bundle"</string>
<string name="action_installations">"Installations"</string>
<string name="action_installing">"Installing"</string>
@@ -296,6 +297,9 @@
<string name="title_installation">"Installation"</string>
<string name="title_installed">"Installed"</string>
<string name="title_installer">"App installer"</string>
<string name="title_install_favourites">"Install favourites?"</string>
<string name="install_favourites_summary">"This will download and install %1$d apps."</string>
<string name="install_favourites_warning">"You may see a separate installation prompt for each app."</string>
<string name="title_language">"Language"</string>
<string name="title_library">"Library"</string>
<string name="title_no_network">"No network"</string>
@@ -508,6 +512,9 @@
<string name="toast_fav_export_failed">Failed to export favourites!</string>
<string name="toast_fav_import_success">Favourites imported!</string>
<string name="toast_fav_export_success">Favourites exported!</string>
<string name="toast_fav_install_enqueued">"Installing %1$d apps"</string>
<string name="toast_fav_install_none">"Nothing to install"</string>
<string name="toast_fav_install_failed">"Failed to fetch app details"</string>
<!-- BlacklistFragment-->
<string name="toast_black_import_failed">Failed to import blacklist!</string>
@@ -586,6 +593,10 @@
<item quantity="one">Permission required</item>
<item quantity="other">Permissions required</item>
</plurals>
<plurals name="favourites_count">
<item quantity="one">%1$d favourite</item>
<item quantity="other">%1$d favourites</item>
</plurals>
<plurals name="notification_installed_summary">
<item quantity="one">%d app installed</item>
<item quantity="other">%d apps installed</item>