From 1da8659de726069a5773759a40830c804410bd26 Mon Sep 17 00:00:00 2001 From: Aayush Gupta Date: Sat, 11 Oct 2025 16:58:03 +0800 Subject: [PATCH 1/4] 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 --- .../compose/ui/details/AppDetailsScreen.kt | 6 +-- .../ui/details/ManualDownloadScreen.kt | 43 ++++++++++--------- .../compose/ui/details/components/Details.kt | 5 +-- .../aurora/store/data/model/DownloadStatus.kt | 5 ++- .../java/com/aurora/store/data/model/State.kt | 3 +- .../aurora/store/data/work/DownloadWorker.kt | 1 + .../viewmodel/details/AppDetailsViewModel.kt | 30 +++---------- 7 files changed, 40 insertions(+), 53 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 72fe0ae0a..8767b4896 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 @@ -135,7 +135,7 @@ fun AppDetailsScreen( exodusReport = exodusReport, onNavigateUp = onNavigateUp, onNavigateToAppDetails = onNavigateToAppDetails, - onDownload = { viewModel.purchase(app!!) }, + onDownload = { viewModel.enqueueDownload(app!!) }, onFavorite = { viewModel.toggleFavourite(app!!) }, onCancelDownload = { viewModel.cancelDownload(app!!) }, onUninstall = { AppInstaller.uninstall(context, packageName) }, @@ -274,14 +274,14 @@ private fun ScreenContentApp( @Composable fun SetupActions() { when (state) { + is AppState.Queued, is AppState.Purchasing, is AppState.Downloading -> { Actions( primaryActionDisplayName = stringResource(R.string.action_open), secondaryActionDisplayName = stringResource(R.string.action_cancel), isPrimaryActionEnabled = false, - onSecondaryAction = onCancelDownload, - isSecondaryActionEnabled = state !is AppState.Purchasing + onSecondaryAction = onCancelDownload ) } diff --git a/app/src/main/java/com/aurora/store/compose/ui/details/ManualDownloadScreen.kt b/app/src/main/java/com/aurora/store/compose/ui/details/ManualDownloadScreen.kt index 4f30dafdb..21e8e1783 100644 --- a/app/src/main/java/com/aurora/store/compose/ui/details/ManualDownloadScreen.kt +++ b/app/src/main/java/com/aurora/store/compose/ui/details/ManualDownloadScreen.kt @@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.Button import androidx.compose.material3.FilledTonalButton @@ -33,7 +34,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier 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.stringResource import androidx.compose.ui.text.AnnotatedString @@ -49,12 +50,12 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.aurora.extensions.adaptiveNavigationIcon import com.aurora.extensions.isWindowCompact -import com.aurora.extensions.toast import com.aurora.gplayapi.data.models.App import com.aurora.store.R import com.aurora.store.compose.composables.InfoComposable import com.aurora.store.compose.composables.TopAppBarComposable import com.aurora.store.compose.preview.AppPreviewProvider +import com.aurora.store.data.model.AppState import com.aurora.store.viewmodel.details.AppDetailsViewModel import kotlinx.coroutines.android.awaitFrame import kotlinx.coroutines.launch @@ -66,26 +67,21 @@ fun ManualDownloadScreen( viewModel: AppDetailsViewModel = hiltViewModel(key = packageName), windowAdaptiveInfo: WindowAdaptiveInfo = currentWindowAdaptiveInfo() ) { - val context = LocalContext.current - val app by viewModel.app.collectAsStateWithLifecycle() + val state by viewModel.state.collectAsStateWithLifecycle() val topAppBarTitle = when { windowAdaptiveInfo.isWindowCompact -> app!!.displayName else -> stringResource(R.string.title_manual_download) } - LaunchedEffect(key1 = Unit) { - viewModel.purchaseStatus.collect { success -> - if (success) { - context.toast(R.string.toast_manual_available) - onNavigateUp() - } else { - context.toast(R.string.toast_manual_unavailable) - } + LaunchedEffect(key1 = state) { + if (state is AppState.Downloading) { + onNavigateUp() } } ScreenContent( + state = state, topAppBarTitle = topAppBarTitle, currentVersionCode = app!!.versionCode, onNavigateUp = onNavigateUp, @@ -98,13 +94,14 @@ fun ManualDownloadScreen( } ) ) - viewModel.purchase(requestedApp) + viewModel.enqueueDownload(requestedApp) } ) } @Composable private fun ScreenContent( + state: AppState = AppState.Unavailable, topAppBarTitle: String? = null, currentVersionCode: Long = 0L, onNavigateUp: () -> Unit = {}, @@ -116,6 +113,7 @@ private fun ScreenContent( val snackBarHostState = remember { SnackbarHostState() } val errorMessage = stringResource(R.string.manual_download_version_error) + val focusManager = LocalFocusManager.current val focusRequester = remember { FocusRequester() } var versionCode by remember { val initText = currentVersionCode.toString() @@ -157,6 +155,7 @@ private fun ScreenContent( modifier = Modifier .fillMaxWidth() .focusRequester(focusRequester), + enabled = !state.inProgress(), value = versionCode, onValueChange = { if (it.text.isDigitsOnly()) { @@ -167,9 +166,8 @@ private fun ScreenContent( }, shape = RoundedCornerShape(10.dp), singleLine = true, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Number - ) + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }) ) } @@ -182,7 +180,7 @@ private fun ScreenContent( onClick = onNavigateUp ) { Text( - text = stringResource(R.string.action_cancel), + text = stringResource(R.string.action_close), maxLines = 1, overflow = TextOverflow.Ellipsis ) @@ -190,7 +188,12 @@ private fun ScreenContent( Button( modifier = Modifier.weight(1F), - onClick = { onDownload(versionCode.text.toLong()) }) { + enabled = !state.inProgress(), + onClick = { + onDownload(versionCode.text.toLong()) + focusManager.clearFocus() + } + ) { Text( text = stringResource(R.string.action_install), maxLines = 1, @@ -204,9 +207,7 @@ private fun ScreenContent( @Preview @Composable -private fun ManualDownloadScreenPreview( - @PreviewParameter(AppPreviewProvider::class) app: App -) { +private fun ManualDownloadScreenPreview(@PreviewParameter(AppPreviewProvider::class) app: App) { ScreenContent( topAppBarTitle = app.displayName, currentVersionCode = app.versionCode diff --git a/app/src/main/java/com/aurora/store/compose/ui/details/components/Details.kt b/app/src/main/java/com/aurora/store/compose/ui/details/components/Details.kt index 8a35abb99..83bd6acaa 100644 --- a/app/src/main/java/com/aurora/store/compose/ui/details/components/Details.kt +++ b/app/src/main/java/com/aurora/store/compose/ui/details/components/Details.kt @@ -97,9 +97,8 @@ fun Details( ) } - AppState.Purchasing::class -> { - stringResource(R.string.preparing_to_install) - } + AppState.Queued::class -> stringResource(R.string.status_queued) + AppState.Purchasing::class -> stringResource(R.string.preparing_to_install) else -> { stringResource(R.string.version, versionName, versionCode) diff --git a/app/src/main/java/com/aurora/store/data/model/DownloadStatus.kt b/app/src/main/java/com/aurora/store/data/model/DownloadStatus.kt index 4e1a8596a..da3dada10 100644 --- a/app/src/main/java/com/aurora/store/data/model/DownloadStatus.kt +++ b/app/src/main/java/com/aurora/store/data/model/DownloadStatus.kt @@ -10,10 +10,11 @@ enum class DownloadStatus(@StringRes val localized: Int) { COMPLETED(R.string.status_completed), QUEUED(R.string.status_queued), UNAVAILABLE(R.string.status_unavailable), - VERIFYING(R.string.status_verifying); + VERIFYING(R.string.status_verifying), + PURCHASING(R.string.preparing_to_install); companion object { val finished = listOf(FAILED, CANCELLED, COMPLETED) - val running = listOf(QUEUED, DOWNLOADING) + val running = listOf(QUEUED, PURCHASING, DOWNLOADING) } } diff --git a/app/src/main/java/com/aurora/store/data/model/State.kt b/app/src/main/java/com/aurora/store/data/model/State.kt index 2dee8e587..ba0c68546 100644 --- a/app/src/main/java/com/aurora/store/data/model/State.kt +++ b/app/src/main/java/com/aurora/store/data/model/State.kt @@ -55,6 +55,7 @@ sealed class AppState { val timeRemaining: Long ) : AppState() + data object Queued : AppState() data object Purchasing : AppState() data class Installing(val progress: Float) : 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 */ 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 } /** diff --git a/app/src/main/java/com/aurora/store/data/work/DownloadWorker.kt b/app/src/main/java/com/aurora/store/data/work/DownloadWorker.kt index 4630cad3a..4f2ee4092 100644 --- a/app/src/main/java/com/aurora/store/data/work/DownloadWorker.kt +++ b/app/src/main/java/com/aurora/store/data/work/DownloadWorker.kt @@ -111,6 +111,7 @@ class DownloadWorker @AssistedInject constructor( setForeground(getForegroundInfo()) // Try to purchase the app if file list is empty + notifyStatus(DownloadStatus.PURCHASING) download.fileList = download.fileList.ifEmpty { purchase(download.packageName, download.versionCode, download.offerType) } diff --git a/app/src/main/java/com/aurora/store/viewmodel/details/AppDetailsViewModel.kt b/app/src/main/java/com/aurora/store/viewmodel/details/AppDetailsViewModel.kt index eda88ad9e..b5e05d61d 100644 --- a/app/src/main/java/com/aurora/store/viewmodel/details/AppDetailsViewModel.kt +++ b/app/src/main/java/com/aurora/store/viewmodel/details/AppDetailsViewModel.kt @@ -17,7 +17,6 @@ import com.aurora.gplayapi.data.models.App import com.aurora.gplayapi.data.models.Review import com.aurora.gplayapi.data.models.details.TestingProgramStatus import com.aurora.gplayapi.helpers.AppDetailsHelper -import com.aurora.gplayapi.helpers.PurchaseHelper import com.aurora.gplayapi.helpers.ReviewsHelper import com.aurora.gplayapi.helpers.web.WebDataSafetyHelper 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.helper.DownloadHelper 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.PlexusReport 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.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.NonCancellable -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.filter @@ -62,7 +59,6 @@ import com.aurora.gplayapi.data.models.datasafety.Report as DataSafetyReport class AppDetailsViewModel @Inject constructor( val authProvider: AuthProvider, @ApplicationContext private val context: Context, - private val purchaseHelper: PurchaseHelper, private val appDetailsHelper: AppDetailsHelper, private val reviewsHelper: ReviewsHelper, private val webDataSafetyHelper: WebDataSafetyHelper, @@ -104,9 +100,6 @@ class AppDetailsViewModel @Inject constructor( private val _favourite = MutableStateFlow(false) val favourite = _favourite.asStateFlow() - private val _purchaseStatus = MutableSharedFlow() - val purchaseStatus = _purchaseStatus.asSharedFlow() - private val download = combine(app, downloadHelper.downloadsList) { a, list -> if (a?.packageName.isNullOrBlank()) return@combine null 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) { - try { - _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) - } + downloadHelper.enqueueApp(app) } } @@ -271,13 +253,15 @@ class AppDetailsViewModel @Inject constructor( }.launchIn(viewModelScope) download.filterNotNull().onEach { - _state.value = when { - it.isRunning -> AppState.Downloading( + _state.value = when (it.status) { + DownloadStatus.DOWNLOADING -> AppState.Downloading( it.progress.toFloat(), it.speed, it.timeRemaining ) + DownloadStatus.QUEUED -> AppState.Queued + DownloadStatus.PURCHASING -> AppState.Purchasing else -> defaultAppState } }.launchIn(viewModelScope) From 4b1dcdc8d3ac481c0f3f72d658b1629a43e3bb56 Mon Sep 17 00:00:00 2001 From: Aayush Gupta Date: Sun, 12 Oct 2025 11:15:41 +0800 Subject: [PATCH 2/4] compose: details: Disable open button if package has no launch intent Signed-off-by: Aayush Gupta --- .../com/aurora/store/compose/ui/details/AppDetailsScreen.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 8767b4896..d25697c53 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 @@ -299,7 +299,9 @@ private fun ScreenContentApp( primaryActionDisplayName = stringResource(R.string.action_open), secondaryActionDisplayName = stringResource(R.string.action_uninstall), onPrimaryAction = onOpen, - onSecondaryAction = onUninstall + onSecondaryAction = onUninstall, + isPrimaryActionEnabled = PackageUtil + .getLaunchIntent(context, app.packageName) != null ) } From 835f153ac16690ebc833d943f08ed8c8421018e7 Mon Sep 17 00:00:00 2001 From: Aayush Gupta Date: Sun, 12 Oct 2025 11:22:18 +0800 Subject: [PATCH 3/4] compose: details: Show loading indicator when a download is in progress Signed-off-by: Aayush Gupta --- .../store/compose/ui/details/ManualDownloadScreen.kt | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/aurora/store/compose/ui/details/ManualDownloadScreen.kt b/app/src/main/java/com/aurora/store/compose/ui/details/ManualDownloadScreen.kt index 21e8e1783..7a883bc00 100644 --- a/app/src/main/java/com/aurora/store/compose/ui/details/ManualDownloadScreen.kt +++ b/app/src/main/java/com/aurora/store/compose/ui/details/ManualDownloadScreen.kt @@ -12,10 +12,12 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredSize import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.Button +import androidx.compose.material3.ContainedLoadingIndicator import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold @@ -167,7 +169,15 @@ private fun ScreenContent( shape = RoundedCornerShape(10.dp), singleLine = true, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }) + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + trailingIcon = { + if (state.inProgress()) { + ContainedLoadingIndicator( + modifier = Modifier + .requiredSize(dimensionResource(R.dimen.icon_size_default)) + ) + } + } ) } From 1276528cce43c8d54ee15b376ed04c3de833729a Mon Sep 17 00:00:00 2001 From: Aayush Gupta Date: Sun, 12 Oct 2025 13:29:29 +0800 Subject: [PATCH 4/4] httpclient: Configure basic caching using okhttp Signed-off-by: Aayush Gupta --- .../aurora/store/data/network/HttpClient.kt | 3 ++- .../store/data/network/OkHttpClientModule.kt | 20 +++++++++++++++++-- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/aurora/store/data/network/HttpClient.kt b/app/src/main/java/com/aurora/store/data/network/HttpClient.kt index df3ab132b..adde9394f 100644 --- a/app/src/main/java/com/aurora/store/data/network/HttpClient.kt +++ b/app/src/main/java/com/aurora/store/data/network/HttpClient.kt @@ -171,8 +171,9 @@ class HttpClient @Inject constructor(private val okHttpClient: OkHttpClient): IH responseBytes = response.body.bytes(), errorString = if (!response.isSuccessful) response.message else String() ).also { + val isCached = if (response.cacheResponse != null) "CACHED" else "NETWORK" _responseCode.value = response.code - Log.i(TAG, "OKHTTP [${response.code}] ${response.request.url}") + Log.i(TAG, "OKHTTP [$isCached] [${response.code}] ${response.request.url}") } } } diff --git a/app/src/main/java/com/aurora/store/data/network/OkHttpClientModule.kt b/app/src/main/java/com/aurora/store/data/network/OkHttpClientModule.kt index 3f22073a2..13fa41d99 100644 --- a/app/src/main/java/com/aurora/store/data/network/OkHttpClientModule.kt +++ b/app/src/main/java/com/aurora/store/data/network/OkHttpClientModule.kt @@ -34,9 +34,11 @@ import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import kotlinx.serialization.json.Json +import okhttp3.Cache import okhttp3.CertificatePinner import okhttp3.OkHttpClient import java.io.ByteArrayInputStream +import java.io.File import java.io.InputStream import java.net.Authenticator import java.net.InetSocketAddress @@ -59,8 +61,13 @@ object OkHttpClientModule { @Provides @Singleton - fun providesOkHttpClientInstance(certPinner: CertificatePinner, proxy: Proxy?): OkHttpClient { + fun providesOkHttpClientInstance( + certificatePinner: CertificatePinner, + proxy: Proxy?, + cache: Cache + ): OkHttpClient { val okHttpClientBuilder = OkHttpClient().newBuilder() + .cache(cache) .proxy(proxy) .connectTimeout(25, TimeUnit.SECONDS) .readTimeout(25, TimeUnit.SECONDS) @@ -70,7 +77,7 @@ object OkHttpClientModule { .followSslRedirects(true) if (!BuildConfig.DEBUG) { - okHttpClientBuilder.certificatePinner(certPinner) + okHttpClientBuilder.certificatePinner(certificatePinner) } return okHttpClientBuilder.build() @@ -122,6 +129,15 @@ object OkHttpClientModule { } } + @Provides + @Singleton + fun providesCacheDir(@ApplicationContext context: Context): Cache { + return Cache( + directory = File(context.cacheDir, "http_cache"), + maxSize = 100L * 1024 * 1024 + ) + } + private fun getGoogleRootCertHashes(context: Context): List { return try { val certs = getX509Certificates(context.resources.openRawResource(R.raw.google_roots_ca))