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.
This commit is contained in:
Rahul Patel
2026-05-30 20:52:46 +05:30
parent 4c49fdc50c
commit 2115ed8479
4 changed files with 58 additions and 19 deletions

View File

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

View File

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

View File

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

View File

@@ -52,6 +52,7 @@
<string name="action_back">"Back"</string>
<string name="action_blacklist">"Blacklist"</string>
<string name="action_blacklist_add">"Add to blacklist"</string>
<string name="action_buy">"Buy @ %1$s"</string>
<string name="action_cancel">"Cancel"</string>
<string name="action_clear">"Clear"</string>
<string name="action_close">"Close"</string>