compose: details: Improvements to purchase handling

Let the worker handle the purchase so it isn't cancelled even if the user
closes the screen immediately after hitting the install button

Signed-off-by: Aayush Gupta <aayushgupta219@gmail.com>
This commit is contained in:
Aayush Gupta
2025-10-11 16:58:03 +08:00
parent 0167ddbb92
commit 1da8659de7
7 changed files with 40 additions and 53 deletions

View File

@@ -135,7 +135,7 @@ fun AppDetailsScreen(
exodusReport = exodusReport, exodusReport = exodusReport,
onNavigateUp = onNavigateUp, onNavigateUp = onNavigateUp,
onNavigateToAppDetails = onNavigateToAppDetails, onNavigateToAppDetails = onNavigateToAppDetails,
onDownload = { viewModel.purchase(app!!) }, onDownload = { viewModel.enqueueDownload(app!!) },
onFavorite = { viewModel.toggleFavourite(app!!) }, onFavorite = { viewModel.toggleFavourite(app!!) },
onCancelDownload = { viewModel.cancelDownload(app!!) }, onCancelDownload = { viewModel.cancelDownload(app!!) },
onUninstall = { AppInstaller.uninstall(context, packageName) }, onUninstall = { AppInstaller.uninstall(context, packageName) },
@@ -274,14 +274,14 @@ private fun ScreenContentApp(
@Composable @Composable
fun SetupActions() { fun SetupActions() {
when (state) { when (state) {
is AppState.Queued,
is AppState.Purchasing, is AppState.Purchasing,
is AppState.Downloading -> { is AppState.Downloading -> {
Actions( Actions(
primaryActionDisplayName = stringResource(R.string.action_open), primaryActionDisplayName = stringResource(R.string.action_open),
secondaryActionDisplayName = stringResource(R.string.action_cancel), secondaryActionDisplayName = stringResource(R.string.action_cancel),
isPrimaryActionEnabled = false, isPrimaryActionEnabled = false,
onSecondaryAction = onCancelDownload, onSecondaryAction = onCancelDownload
isSecondaryActionEnabled = state !is AppState.Purchasing
) )
} }

View File

@@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.FilledTonalButton
@@ -33,7 +34,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
@@ -49,12 +50,12 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.aurora.extensions.adaptiveNavigationIcon import com.aurora.extensions.adaptiveNavigationIcon
import com.aurora.extensions.isWindowCompact import com.aurora.extensions.isWindowCompact
import com.aurora.extensions.toast
import com.aurora.gplayapi.data.models.App import com.aurora.gplayapi.data.models.App
import com.aurora.store.R import com.aurora.store.R
import com.aurora.store.compose.composables.InfoComposable import com.aurora.store.compose.composables.InfoComposable
import com.aurora.store.compose.composables.TopAppBarComposable import com.aurora.store.compose.composables.TopAppBarComposable
import com.aurora.store.compose.preview.AppPreviewProvider import com.aurora.store.compose.preview.AppPreviewProvider
import com.aurora.store.data.model.AppState
import com.aurora.store.viewmodel.details.AppDetailsViewModel import com.aurora.store.viewmodel.details.AppDetailsViewModel
import kotlinx.coroutines.android.awaitFrame import kotlinx.coroutines.android.awaitFrame
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -66,26 +67,21 @@ fun ManualDownloadScreen(
viewModel: AppDetailsViewModel = hiltViewModel(key = packageName), viewModel: AppDetailsViewModel = hiltViewModel(key = packageName),
windowAdaptiveInfo: WindowAdaptiveInfo = currentWindowAdaptiveInfo() windowAdaptiveInfo: WindowAdaptiveInfo = currentWindowAdaptiveInfo()
) { ) {
val context = LocalContext.current
val app by viewModel.app.collectAsStateWithLifecycle() val app by viewModel.app.collectAsStateWithLifecycle()
val state by viewModel.state.collectAsStateWithLifecycle()
val topAppBarTitle = when { val topAppBarTitle = when {
windowAdaptiveInfo.isWindowCompact -> app!!.displayName windowAdaptiveInfo.isWindowCompact -> app!!.displayName
else -> stringResource(R.string.title_manual_download) else -> stringResource(R.string.title_manual_download)
} }
LaunchedEffect(key1 = Unit) { LaunchedEffect(key1 = state) {
viewModel.purchaseStatus.collect { success -> if (state is AppState.Downloading) {
if (success) { onNavigateUp()
context.toast(R.string.toast_manual_available)
onNavigateUp()
} else {
context.toast(R.string.toast_manual_unavailable)
}
} }
} }
ScreenContent( ScreenContent(
state = state,
topAppBarTitle = topAppBarTitle, topAppBarTitle = topAppBarTitle,
currentVersionCode = app!!.versionCode, currentVersionCode = app!!.versionCode,
onNavigateUp = onNavigateUp, onNavigateUp = onNavigateUp,
@@ -98,13 +94,14 @@ fun ManualDownloadScreen(
} }
) )
) )
viewModel.purchase(requestedApp) viewModel.enqueueDownload(requestedApp)
} }
) )
} }
@Composable @Composable
private fun ScreenContent( private fun ScreenContent(
state: AppState = AppState.Unavailable,
topAppBarTitle: String? = null, topAppBarTitle: String? = null,
currentVersionCode: Long = 0L, currentVersionCode: Long = 0L,
onNavigateUp: () -> Unit = {}, onNavigateUp: () -> Unit = {},
@@ -116,6 +113,7 @@ private fun ScreenContent(
val snackBarHostState = remember { SnackbarHostState() } val snackBarHostState = remember { SnackbarHostState() }
val errorMessage = stringResource(R.string.manual_download_version_error) val errorMessage = stringResource(R.string.manual_download_version_error)
val focusManager = LocalFocusManager.current
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
var versionCode by remember { var versionCode by remember {
val initText = currentVersionCode.toString() val initText = currentVersionCode.toString()
@@ -157,6 +155,7 @@ private fun ScreenContent(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.focusRequester(focusRequester), .focusRequester(focusRequester),
enabled = !state.inProgress(),
value = versionCode, value = versionCode,
onValueChange = { onValueChange = {
if (it.text.isDigitsOnly()) { if (it.text.isDigitsOnly()) {
@@ -167,9 +166,8 @@ private fun ScreenContent(
}, },
shape = RoundedCornerShape(10.dp), shape = RoundedCornerShape(10.dp),
singleLine = true, singleLine = true,
keyboardOptions = KeyboardOptions( keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
keyboardType = KeyboardType.Number keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() })
)
) )
} }
@@ -182,7 +180,7 @@ private fun ScreenContent(
onClick = onNavigateUp onClick = onNavigateUp
) { ) {
Text( Text(
text = stringResource(R.string.action_cancel), text = stringResource(R.string.action_close),
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
@@ -190,7 +188,12 @@ private fun ScreenContent(
Button( Button(
modifier = Modifier.weight(1F), modifier = Modifier.weight(1F),
onClick = { onDownload(versionCode.text.toLong()) }) { enabled = !state.inProgress(),
onClick = {
onDownload(versionCode.text.toLong())
focusManager.clearFocus()
}
) {
Text( Text(
text = stringResource(R.string.action_install), text = stringResource(R.string.action_install),
maxLines = 1, maxLines = 1,
@@ -204,9 +207,7 @@ private fun ScreenContent(
@Preview @Preview
@Composable @Composable
private fun ManualDownloadScreenPreview( private fun ManualDownloadScreenPreview(@PreviewParameter(AppPreviewProvider::class) app: App) {
@PreviewParameter(AppPreviewProvider::class) app: App
) {
ScreenContent( ScreenContent(
topAppBarTitle = app.displayName, topAppBarTitle = app.displayName,
currentVersionCode = app.versionCode currentVersionCode = app.versionCode

View File

@@ -97,9 +97,8 @@ fun Details(
) )
} }
AppState.Purchasing::class -> { AppState.Queued::class -> stringResource(R.string.status_queued)
stringResource(R.string.preparing_to_install) AppState.Purchasing::class -> stringResource(R.string.preparing_to_install)
}
else -> { else -> {
stringResource(R.string.version, versionName, versionCode) stringResource(R.string.version, versionName, versionCode)

View File

@@ -10,10 +10,11 @@ enum class DownloadStatus(@StringRes val localized: Int) {
COMPLETED(R.string.status_completed), COMPLETED(R.string.status_completed),
QUEUED(R.string.status_queued), QUEUED(R.string.status_queued),
UNAVAILABLE(R.string.status_unavailable), UNAVAILABLE(R.string.status_unavailable),
VERIFYING(R.string.status_verifying); VERIFYING(R.string.status_verifying),
PURCHASING(R.string.preparing_to_install);
companion object { companion object {
val finished = listOf(FAILED, CANCELLED, COMPLETED) val finished = listOf(FAILED, CANCELLED, COMPLETED)
val running = listOf(QUEUED, DOWNLOADING) val running = listOf(QUEUED, PURCHASING, DOWNLOADING)
} }
} }

View File

@@ -55,6 +55,7 @@ sealed class AppState {
val timeRemaining: Long val timeRemaining: Long
) : AppState() ) : AppState()
data object Queued : AppState()
data object Purchasing : AppState() data object Purchasing : AppState()
data class Installing(val progress: Float) : AppState() data class Installing(val progress: Float) : AppState()
data class Error(val message: String?) : AppState() data class Error(val message: String?) : AppState()
@@ -68,7 +69,7 @@ sealed class AppState {
* Whether there is some sort of ongoing process related to the app * Whether there is some sort of ongoing process related to the app
*/ */
fun inProgress(): Boolean { fun inProgress(): Boolean {
return this is Downloading || this is Installing || this is Purchasing return this is Downloading || this is Installing || this is Purchasing || this is Queued
} }
/** /**

View File

@@ -111,6 +111,7 @@ class DownloadWorker @AssistedInject constructor(
setForeground(getForegroundInfo()) setForeground(getForegroundInfo())
// Try to purchase the app if file list is empty // Try to purchase the app if file list is empty
notifyStatus(DownloadStatus.PURCHASING)
download.fileList = download.fileList.ifEmpty { download.fileList = download.fileList.ifEmpty {
purchase(download.packageName, download.versionCode, download.offerType) purchase(download.packageName, download.versionCode, download.offerType)
} }

View File

@@ -17,7 +17,6 @@ import com.aurora.gplayapi.data.models.App
import com.aurora.gplayapi.data.models.Review import com.aurora.gplayapi.data.models.Review
import com.aurora.gplayapi.data.models.details.TestingProgramStatus import com.aurora.gplayapi.data.models.details.TestingProgramStatus
import com.aurora.gplayapi.helpers.AppDetailsHelper import com.aurora.gplayapi.helpers.AppDetailsHelper
import com.aurora.gplayapi.helpers.PurchaseHelper
import com.aurora.gplayapi.helpers.ReviewsHelper import com.aurora.gplayapi.helpers.ReviewsHelper
import com.aurora.gplayapi.helpers.web.WebDataSafetyHelper import com.aurora.gplayapi.helpers.web.WebDataSafetyHelper
import com.aurora.gplayapi.network.IHttpClient import com.aurora.gplayapi.network.IHttpClient
@@ -26,6 +25,7 @@ import com.aurora.store.BuildConfig
import com.aurora.store.data.event.InstallerEvent import com.aurora.store.data.event.InstallerEvent
import com.aurora.store.data.helper.DownloadHelper import com.aurora.store.data.helper.DownloadHelper
import com.aurora.store.data.model.AppState import com.aurora.store.data.model.AppState
import com.aurora.store.data.model.DownloadStatus
import com.aurora.store.data.model.ExodusReport import com.aurora.store.data.model.ExodusReport
import com.aurora.store.data.model.PlexusReport import com.aurora.store.data.model.PlexusReport
import com.aurora.store.data.model.Report import com.aurora.store.data.model.Report
@@ -40,11 +40,8 @@ import com.aurora.store.util.Preferences.PREFERENCE_UPDATES_EXTENDED
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
@@ -62,7 +59,6 @@ import com.aurora.gplayapi.data.models.datasafety.Report as DataSafetyReport
class AppDetailsViewModel @Inject constructor( class AppDetailsViewModel @Inject constructor(
val authProvider: AuthProvider, val authProvider: AuthProvider,
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
private val purchaseHelper: PurchaseHelper,
private val appDetailsHelper: AppDetailsHelper, private val appDetailsHelper: AppDetailsHelper,
private val reviewsHelper: ReviewsHelper, private val reviewsHelper: ReviewsHelper,
private val webDataSafetyHelper: WebDataSafetyHelper, private val webDataSafetyHelper: WebDataSafetyHelper,
@@ -104,9 +100,6 @@ class AppDetailsViewModel @Inject constructor(
private val _favourite = MutableStateFlow(false) private val _favourite = MutableStateFlow(false)
val favourite = _favourite.asStateFlow() val favourite = _favourite.asStateFlow()
private val _purchaseStatus = MutableSharedFlow<Boolean>()
val purchaseStatus = _purchaseStatus.asSharedFlow()
private val download = combine(app, downloadHelper.downloadsList) { a, list -> private val download = combine(app, downloadHelper.downloadsList) { a, list ->
if (a?.packageName.isNullOrBlank()) return@combine null if (a?.packageName.isNullOrBlank()) return@combine null
list.find { d -> d.packageName == a.packageName } list.find { d -> d.packageName == a.packageName }
@@ -219,20 +212,9 @@ class AppDetailsViewModel @Inject constructor(
} }
} }
fun purchase(app: App) { fun enqueueDownload(app: App) {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
try { downloadHelper.enqueueApp(app)
_state.value = AppState.Purchasing
val files = purchaseHelper.purchase(app.packageName, app.versionCode, app.offerType)
_purchaseStatus.emit(files.isNotEmpty())
if (files.isNotEmpty()) {
downloadHelper.enqueueApp(app.copy(fileList = files.toMutableList()))
}
} catch (exception: Exception) {
_state.value = defaultAppState
_purchaseStatus.emit(false)
Log.e(TAG, "Failed to purchase the app", exception)
}
} }
} }
@@ -271,13 +253,15 @@ class AppDetailsViewModel @Inject constructor(
}.launchIn(viewModelScope) }.launchIn(viewModelScope)
download.filterNotNull().onEach { download.filterNotNull().onEach {
_state.value = when { _state.value = when (it.status) {
it.isRunning -> AppState.Downloading( DownloadStatus.DOWNLOADING -> AppState.Downloading(
it.progress.toFloat(), it.progress.toFloat(),
it.speed, it.speed,
it.timeRemaining it.timeRemaining
) )
DownloadStatus.QUEUED -> AppState.Queued
DownloadStatus.PURCHASING -> AppState.Purchasing
else -> defaultAppState else -> defaultAppState
} }
}.launchIn(viewModelScope) }.launchIn(viewModelScope)