From 2115ed8479a5544c6649b0a68854028c4d4ab421 Mon Sep 17 00:00:00 2001 From: Rahul Patel Date: Sat, 30 May 2026 20:52:46 +0530 Subject: [PATCH 1/9] Add buy flow for paid apps in app details Resolves #1499 Surface a 'Buy @ price' action on the install error sheet for paid apps on signed-in accounts, opening the Play Store listing to purchase. For anonymous accounts, show the price on a disabled install button and disable manual downloads since paid apps can't be acquired. --- .../compose/ui/details/AppDetailsScreen.kt | 46 +++++++++++++------ .../compose/ui/details/menu/AppDetailsMenu.kt | 3 +- .../compose/ui/sheets/InstallErrorSheet.kt | 27 +++++++++-- app/src/main/res/values/strings.xml | 1 + 4 files changed, 58 insertions(+), 19 deletions(-) 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 aa1d148cf..fae82eb6b 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 @@ -7,6 +7,7 @@ package com.aurora.store.compose.ui.details import android.content.ActivityNotFoundException +import android.content.Context import android.content.Intent import androidx.compose.animation.AnimatedContent import androidx.compose.animation.fadeIn @@ -135,6 +136,8 @@ fun AppDetailsScreen( app = loadedApp, error = err.error, extra = err.extra, + isAnonymous = viewModel.authProvider.isAnonymous, + onBuy = { openPlayStore(context, loadedApp.packageName) }, onDismiss = viewModel::dismissInstallError ) } @@ -198,6 +201,21 @@ fun AppDetailsScreen( } } +/** + * Opens the given app's Play Store listing, preferring the Play Store app when available and + * falling back to whichever activity can handle the web listing (e.g. a browser). + */ +private fun openPlayStore(context: Context, packageName: String) { + val uri = "${Constants.SHARE_URL}$packageName".toUri() + val intent = Intent(Intent.ACTION_VIEW).apply { data = uri } + + if (intent.resolveActivity(context.packageManager) != null) { + context.startActivity(intent.apply { setPackage(Constants.PACKAGE_NAME_PLAY_STORE) }) + } else { + context.startActivity(intent) + } +} + private fun stateKey(state: AppState, app: App?): String = when { state is AppState.Error -> "error" state is AppState.Loading || app == null -> "loading" @@ -261,6 +279,11 @@ private fun ScreenContentApp( onForceRestart: () -> Unit = {} ) { val context = LocalContext.current + + // Anonymous accounts can't purchase, so a paid app can neither be installed nor manually + // downloaded (any version) by them. Free apps are always acquirable. + val canAcquire = app.isFree || !isAnonymous + var scaffoldDirective = calculatePaneScaffoldDirective(windowAdaptiveInfo) if (forceSinglePane) { @@ -317,7 +340,11 @@ private fun ScreenContentApp( @Composable fun SetupMenu() { - AppDetailsMenu(isFavorite = isFavorite, state = state) { menuItem -> + AppDetailsMenu( + isFavorite = isFavorite, + state = state, + canManualDownload = canAcquire + ) { menuItem -> when (menuItem) { MenuItem.FAVORITE -> onFavorite() @@ -333,20 +360,7 @@ private fun ScreenContentApp( ShortcutManagerUtil.requestPinShortcut(context, app.packageName) } - MenuItem.PLAY_STORE -> { - val uri = "${Constants.SHARE_URL}${app.packageName}".toUri() - val intent = Intent(Intent.ACTION_VIEW).apply { data = uri } - - if (intent.resolveActivity(context.packageManager) != null) { - context.startActivity( - intent.apply { - setPackage(Constants.PACKAGE_NAME_PLAY_STORE) - } - ) - } else { - context.startActivity(intent) - } - } + MenuItem.PLAY_STORE -> openPlayStore(context, app.packageName) } } } @@ -413,6 +427,8 @@ private fun ScreenContentApp( Actions( primaryActionDisplayName = primaryActionName, secondaryActionDisplayName = stringResource(R.string.title_manual_download), + isPrimaryActionEnabled = canAcquire, + isSecondaryActionEnabled = canAcquire, onPrimaryAction = ::onInstall, onSecondaryAction = { showExtraPane(ExtraScreen.ManualDownload) } ) diff --git a/app/src/main/java/com/aurora/store/compose/ui/details/menu/AppDetailsMenu.kt b/app/src/main/java/com/aurora/store/compose/ui/details/menu/AppDetailsMenu.kt index 07f909a3d..afd70198a 100644 --- a/app/src/main/java/com/aurora/store/compose/ui/details/menu/AppDetailsMenu.kt +++ b/app/src/main/java/com/aurora/store/compose/ui/details/menu/AppDetailsMenu.kt @@ -42,6 +42,7 @@ fun AppDetailsMenu( state: AppState = AppState.Unavailable, isFavorite: Boolean = false, isExpanded: Boolean = false, + canManualDownload: Boolean = true, onMenuItemClicked: (menuItem: MenuItem) -> Unit = {} ) { val context = LocalContext.current @@ -80,7 +81,7 @@ fun AppDetailsMenu( DropdownMenuItem( text = { Text(text = stringResource(R.string.title_manual_download)) }, onClick = { onClick(MenuItem.MANUAL_DOWNLOAD) }, - enabled = !state.inProgress() + enabled = canManualDownload && !state.inProgress() ) DropdownMenuItem( text = { Text(text = stringResource(R.string.action_info)) }, diff --git a/app/src/main/java/com/aurora/store/compose/ui/sheets/InstallErrorSheet.kt b/app/src/main/java/com/aurora/store/compose/ui/sheets/InstallErrorSheet.kt index 34a4dd87f..c93ef203c 100644 --- a/app/src/main/java/com/aurora/store/compose/ui/sheets/InstallErrorSheet.kt +++ b/app/src/main/java/com/aurora/store/compose/ui/sheets/InstallErrorSheet.kt @@ -16,6 +16,7 @@ import androidx.compose.foundation.layout.requiredSize import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme @@ -41,9 +42,20 @@ import com.aurora.store.R @OptIn(ExperimentalMaterial3Api::class) @Composable -fun InstallErrorSheet(app: App, error: String?, extra: String?, onDismiss: () -> Unit) { +fun InstallErrorSheet( + app: App, + error: String?, + extra: String?, + isAnonymous: Boolean = true, + onBuy: () -> Unit = {}, + onDismiss: () -> Unit +) { val context = LocalContext.current + // A paid app that failed to install for a signed-in (non-anonymous) account is almost + // always one that hasn't been purchased yet; offer a shortcut to buy it on the Play Store. + val canBuy = !app.isFree && !isAnonymous + ModalBottomSheet( onDismissRequest = onDismiss, sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) @@ -53,7 +65,7 @@ fun InstallErrorSheet(app: App, error: String?, extra: String?, onDismiss: () -> .fillMaxWidth() .verticalScroll(rememberScrollState()) ) { - Header(app = app) + Header(app = app, showBuy = canBuy, onBuy = onBuy) HorizontalDivider( modifier = Modifier.padding( @@ -101,7 +113,7 @@ fun InstallErrorSheet(app: App, error: String?, extra: String?, onDismiss: () -> } @Composable -private fun Header(app: App) { +private fun Header(app: App, showBuy: Boolean = false, onBuy: () -> Unit = {}) { Row( modifier = Modifier .fillMaxWidth() @@ -137,5 +149,14 @@ private fun Header(app: App) { overflow = TextOverflow.Ellipsis ) } + if (showBuy) { + Button(onClick = onBuy) { + Text( + text = stringResource(R.string.action_buy, app.price), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 87f9d60cb..a3ffdaf7b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -52,6 +52,7 @@ "Back" "Blacklist" "Add to blacklist" + "Buy @ %1$s" "Cancel" "Clear" "Close" From cb682f1f0bad340cada58e2b61b79596691944c3 Mon Sep 17 00:00:00 2001 From: Rahul Patel Date: Sat, 30 May 2026 23:17:41 +0530 Subject: [PATCH 2/9] 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 From f1064ceab92aaefcfe07c614cc05b2d644ff10a6 Mon Sep 17 00:00:00 2001 From: Rahul Patel Date: Sun, 31 May 2026 01:47:24 +0530 Subject: [PATCH 3/9] Add confirmation gate for external app-listing deep links Ad networks can exploit Aurora's market:// and play.google.com VIEW intent-filters to launch the app straight into a listing without user intent (issue #1450). Route these external deep links through a new translucent DeepLinkConfirmActivity that shows a Play Store-style bottom sheet over the launching app; the listing only opens after an explicit tap, defeating auto-launch regardless of referrer. - DeepLinkConfirmActivity: resolves the listing, shows the sheet, then forwards to ComposeActivity via the Screen parcel (Open) or finishes (Cancel). Forwards directly when the user opts out. - DeepLinkConfirmSheet: bottom sheet matching the existing sheets/ convention; shows the target id and the launching app when known. - Move the VIEW intent-filters off ComposeActivity onto the new activity, using a transparent edge-to-edge translucent theme. - Add a "Confirm external links" toggle in Security (default on). --- app/src/main/AndroidManifest.xml | 10 +- .../java/com/aurora/store/ComposeActivity.kt | 2 +- .../aurora/store/DeepLinkConfirmActivity.kt | 105 +++++++++++++ .../security/SecurityPreferenceScreen.kt | 25 ++++ .../compose/ui/sheets/DeepLinkConfirmSheet.kt | 140 ++++++++++++++++++ .../java/com/aurora/store/util/Preferences.kt | 2 + app/src/main/res/values/strings.xml | 5 + app/src/main/res/values/themes.xml | 8 + 8 files changed, 295 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/com/aurora/store/DeepLinkConfirmActivity.kt create mode 100644 app/src/main/java/com/aurora/store/compose/ui/sheets/DeepLinkConfirmSheet.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 0bf3e078d..e9bcc3f1c 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -103,6 +103,15 @@ + + + + @@ -121,7 +130,6 @@ - diff --git a/app/src/main/java/com/aurora/store/ComposeActivity.kt b/app/src/main/java/com/aurora/store/ComposeActivity.kt index b98e6b6af..5ca6de392 100644 --- a/app/src/main/java/com/aurora/store/ComposeActivity.kt +++ b/app/src/main/java/com/aurora/store/ComposeActivity.kt @@ -152,7 +152,7 @@ class ComposeActivity : FragmentActivity() { } private fun resolveStartDestination(): Screen { - // Parcel-based navigation (e.g. from NotificationUtil) + // Parcel-based navigation (e.g. from NotificationUtil or DeepLinkConfirmActivity) IntentCompat.getParcelableExtra(intent, Screen.PARCEL_KEY, Screen::class.java) ?.let { return it } diff --git a/app/src/main/java/com/aurora/store/DeepLinkConfirmActivity.kt b/app/src/main/java/com/aurora/store/DeepLinkConfirmActivity.kt new file mode 100644 index 000000000..49ef5f3b4 --- /dev/null +++ b/app/src/main/java/com/aurora/store/DeepLinkConfirmActivity.kt @@ -0,0 +1,105 @@ +/* + * SPDX-FileCopyrightText: 2026 Aurora OSS + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.aurora.store + +import android.content.Intent +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.fragment.app.FragmentActivity +import com.aurora.store.compose.navigation.Screen +import com.aurora.store.compose.theme.AuroraTheme +import com.aurora.store.compose.ui.sheets.DeepLinkConfirmSheet +import com.aurora.store.util.Preferences + +/** + * Translucent trampoline that gates external [Intent.ACTION_VIEW] app/developer listing deep links + * (market:// and play.google.com links). These are the vector ads exploit to launch Aurora into a + * listing without intent, so a Play Store-style confirmation sheet is shown floating over the + * launching app before forwarding to [ComposeActivity]. When the user has opted out, or the intent + * doesn't resolve to a listing, it forwards immediately without prompting. + */ +class DeepLinkConfirmActivity : FragmentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge() + super.onCreate(savedInstanceState) + + val target = resolveDeepLink() + val shouldConfirm = target != null && + Preferences.getBoolean(this, Preferences.PREFERENCE_CONFIRM_EXTERNAL_DEEPLINK, true) + + if (!shouldConfirm) { + forwardToAurora(target) + return + } + + val referrerLabel = resolveReferrerLabel() + setContent { + AuroraTheme { + DeepLinkConfirmSheet( + targetLabel = target.deepLinkLabel(), + sourceLabel = referrerLabel, + onOpen = { forwardToAurora(target) }, + onDismiss = { finish() } + ) + } + } + } + + /** + * Resolves the listing requested by the incoming ACTION_VIEW intent, or null when the intent + * carries no app/developer id. + */ + private fun resolveDeepLink(): Screen? { + if (intent.action != Intent.ACTION_VIEW) return null + + val data = intent.data + val path = data?.path.orEmpty() + val id = data?.getQueryParameter("id") ?: return null + return when { + path.contains("/apps/dev") -> Screen.DevProfile(id) + else -> Screen.AppDetails(id) + } + } + + /** + * Best-effort human-readable name of the app that fired the intent, derived from the activity + * referrer. Resolves an android-app:// referrer to its app label, falling back to the raw host. + * Returns null when no referrer is available. + */ + private fun resolveReferrerLabel(): String? { + val ref = referrer ?: return null + val pkg = if (ref.scheme == "android-app") ref.host else null + if (pkg != null) { + return runCatching { + packageManager.getApplicationLabel( + packageManager.getApplicationInfo(pkg, 0) + ).toString() + }.getOrDefault(pkg) + } + return ref.host ?: ref.toString() + } + + private fun forwardToAurora(target: Screen?) { + startActivity( + Intent(this, ComposeActivity::class.java).apply { + target?.let { putExtra(Screen.PARCEL_KEY, it) } + // Start ComposeActivity fresh so the parcel is honoured even when Aurora is already + // running; without this a reused instance keeps its current screen. Mirrors the + // deep-link PendingIntents in NotificationUtil. + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + } + ) + finish() + } + + private fun Screen.deepLinkLabel(): String = when (this) { + is Screen.AppDetails -> packageName + is Screen.DevProfile -> developerId + else -> "" + } +} diff --git a/app/src/main/java/com/aurora/store/compose/ui/preferences/security/SecurityPreferenceScreen.kt b/app/src/main/java/com/aurora/store/compose/ui/preferences/security/SecurityPreferenceScreen.kt index 134fff43a..77fa2ac5c 100644 --- a/app/src/main/java/com/aurora/store/compose/ui/preferences/security/SecurityPreferenceScreen.kt +++ b/app/src/main/java/com/aurora/store/compose/ui/preferences/security/SecurityPreferenceScreen.kt @@ -30,6 +30,7 @@ import com.aurora.store.compose.preview.ThemePreviewProvider import com.aurora.store.util.AppLockAuthenticator import com.aurora.store.util.Preferences import com.aurora.store.util.Preferences.PREFERENCE_APP_LOCK_ENABLED +import com.aurora.store.util.Preferences.PREFERENCE_CONFIRM_EXTERNAL_DEEPLINK import com.aurora.store.util.save @Composable @@ -43,6 +44,11 @@ private fun ScreenContent() { var appLockEnabled by remember { mutableStateOf(Preferences.getBoolean(context, PREFERENCE_APP_LOCK_ENABLED, false)) } + var confirmDeepLink by remember { + mutableStateOf( + Preferences.getBoolean(context, PREFERENCE_CONFIRM_EXTERNAL_DEEPLINK, true) + ) + } fun setAppLock(enabled: Boolean) { // Refuse to enable the lock when the device has no biometric or screen-lock to fall back on @@ -54,6 +60,11 @@ private fun ScreenContent() { context.save(PREFERENCE_APP_LOCK_ENABLED, enabled) } + fun setConfirmDeepLink(enabled: Boolean) { + confirmDeepLink = enabled + context.save(PREFERENCE_CONFIRM_EXTERNAL_DEEPLINK, enabled) + } + Scaffold( topBar = { TopAppBar(title = stringResource(R.string.title_security)) } ) { paddingValues -> @@ -75,6 +86,20 @@ private fun ScreenContent() { } ) } + + item { + ListItem( + modifier = Modifier.clickable { setConfirmDeepLink(!confirmDeepLink) }, + headlineContent = { Text(stringResource(R.string.confirm_deeplink_title)) }, + supportingContent = { Text(stringResource(R.string.confirm_deeplink_summary)) }, + trailingContent = { + Switch( + checked = confirmDeepLink, + onCheckedChange = { setConfirmDeepLink(it) } + ) + } + ) + } } } } diff --git a/app/src/main/java/com/aurora/store/compose/ui/sheets/DeepLinkConfirmSheet.kt b/app/src/main/java/com/aurora/store/compose/ui/sheets/DeepLinkConfirmSheet.kt new file mode 100644 index 000000000..f8d91b2d0 --- /dev/null +++ b/app/src/main/java/com/aurora/store/compose/ui/sheets/DeepLinkConfirmSheet.kt @@ -0,0 +1,140 @@ +/* + * SPDX-FileCopyrightText: 2026 Aurora OSS + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.aurora.store.compose.ui.sheets + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import com.aurora.store.R + +/** + * Play Store-style bottom sheet shown before opening an app/developer listing from an external + * deep link. Requires an explicit tap so ads cannot silently launch Aurora into a listing. + * + * @param targetLabel Package name or developer id the link points to + * @param sourceLabel Human-readable name of the app that fired the link, or null if unknown + * @param onOpen Invoked when the user confirms opening the listing + * @param onDismiss Invoked when the user cancels or dismisses the sheet + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DeepLinkConfirmSheet( + targetLabel: String, + sourceLabel: String?, + onOpen: () -> Unit, + onDismiss: () -> Unit +) { + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding( + horizontal = dimensionResource(R.dimen.spacing_medium), + vertical = dimensionResource(R.dimen.spacing_xsmall) + ), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy( + dimensionResource(R.dimen.spacing_medium) + ) + ) { + Image( + painter = painterResource(R.drawable.ic_logo_alt), + contentDescription = null, + modifier = Modifier.requiredSize(dimensionResource(R.dimen.icon_size_medium)) + ) + Text( + text = stringResource(R.string.confirm_deeplink_sheet_title), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.weight(1f) + ) + } + + HorizontalDivider( + modifier = Modifier.padding(vertical = dimensionResource(R.dimen.spacing_xsmall)) + ) + + Column( + modifier = Modifier.padding( + horizontal = dimensionResource(R.dimen.spacing_medium), + vertical = dimensionResource(R.dimen.spacing_xsmall) + ), + verticalArrangement = Arrangement.spacedBy( + dimensionResource(R.dimen.spacing_xsmall) + ) + ) { + Text( + text = stringResource(R.string.confirm_deeplink_sheet_message), + style = MaterialTheme.typography.bodyMedium + ) + Text( + text = targetLabel, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + if (!sourceLabel.isNullOrBlank()) { + Text( + text = stringResource(R.string.confirm_deeplink_sheet_source, sourceLabel), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding( + horizontal = dimensionResource(R.dimen.spacing_xsmall), + vertical = dimensionResource(R.dimen.spacing_xsmall) + ), + horizontalArrangement = Arrangement.End + ) { + TextButton(onClick = onDismiss) { + Text(text = stringResource(R.string.action_cancel)) + } + TextButton(onClick = onOpen) { + Text(text = stringResource(R.string.action_open)) + } + } + + Spacer(Modifier.navigationBarsPadding()) + } + } +} diff --git a/app/src/main/java/com/aurora/store/util/Preferences.kt b/app/src/main/java/com/aurora/store/util/Preferences.kt index e01de6560..8741e82c4 100644 --- a/app/src/main/java/com/aurora/store/util/Preferences.kt +++ b/app/src/main/java/com/aurora/store/util/Preferences.kt @@ -81,6 +81,8 @@ object Preferences { const val PREFERENCE_APP_LOCK_ENABLED = "PREFERENCE_APP_LOCK_ENABLED" const val PREFERENCE_APP_LOCK_TIMEOUT = "PREFERENCE_APP_LOCK_TIMEOUT" + const val PREFERENCE_CONFIRM_EXTERNAL_DEEPLINK = "PREFERENCE_CONFIRM_EXTERNAL_DEEPLINK" + private var prefs: SharedPreferences? = null fun getPrefs(context: Context): SharedPreferences = when (BuildConfig.FLAVOR) { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index fe74bacc8..571357842 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -191,6 +191,11 @@ Unlock Unlock Aurora Store Enter phone screen lock pattern, PIN, password or fingerprint + Confirm external links + Ask before opening an app listing from a link, preventing ads from launching Aurora Store without your consent. + Continue to Aurora Store? + An external app wants to open Aurora Store + Opened from %1$s "Aurora Services is available and ready to install." Install Aurora Services 1.0.9 or above, or change the installer. Set up Aurora Services and grant all permissions first. diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index cc9850a12..f5a9b060e 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -35,6 +35,14 @@ true + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index afc965525..bf7280757 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -414,6 +414,8 @@ Follow system Light Dark + Dynamic colors + Use colors from your wallpaper. Turn off for Aurora\'s brand palette. Layout Select default tab App links diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index f5a9b060e..0171d8f97 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -23,6 +23,8 @@ @style/Chip.Filter @style/AppTheme.BottomSheetStyle @android:color/transparent + + @color/colorAccent