Merge branch 'dev'

This commit is contained in:
Aayush Gupta
2025-10-12 13:32:07 +08:00
9 changed files with 73 additions and 57 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
) )
} }
@@ -299,7 +299,9 @@ private fun ScreenContentApp(
primaryActionDisplayName = stringResource(R.string.action_open), primaryActionDisplayName = stringResource(R.string.action_open),
secondaryActionDisplayName = stringResource(R.string.action_uninstall), secondaryActionDisplayName = stringResource(R.string.action_uninstall),
onPrimaryAction = onOpen, onPrimaryAction = onOpen,
onSecondaryAction = onUninstall onSecondaryAction = onUninstall,
isPrimaryActionEnabled = PackageUtil
.getLaunchIntent(context, app.packageName) != null
) )
} }

View File

@@ -12,9 +12,12 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth 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.layout.requiredSize
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.ContainedLoadingIndicator
import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
@@ -33,7 +36,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 +52,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 +69,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 +96,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 +115,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 +157,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 +168,16 @@ 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() }),
) trailingIcon = {
if (state.inProgress()) {
ContainedLoadingIndicator(
modifier = Modifier
.requiredSize(dimensionResource(R.dimen.icon_size_default))
)
}
}
) )
} }
@@ -182,7 +190,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 +198,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 +217,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

@@ -171,8 +171,9 @@ class HttpClient @Inject constructor(private val okHttpClient: OkHttpClient): IH
responseBytes = response.body.bytes(), responseBytes = response.body.bytes(),
errorString = if (!response.isSuccessful) response.message else String() errorString = if (!response.isSuccessful) response.message else String()
).also { ).also {
val isCached = if (response.cacheResponse != null) "CACHED" else "NETWORK"
_responseCode.value = response.code _responseCode.value = response.code
Log.i(TAG, "OKHTTP [${response.code}] ${response.request.url}") Log.i(TAG, "OKHTTP [$isCached] [${response.code}] ${response.request.url}")
} }
} }
} }

View File

@@ -34,9 +34,11 @@ import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import okhttp3.Cache
import okhttp3.CertificatePinner import okhttp3.CertificatePinner
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import java.io.ByteArrayInputStream import java.io.ByteArrayInputStream
import java.io.File
import java.io.InputStream import java.io.InputStream
import java.net.Authenticator import java.net.Authenticator
import java.net.InetSocketAddress import java.net.InetSocketAddress
@@ -59,8 +61,13 @@ object OkHttpClientModule {
@Provides @Provides
@Singleton @Singleton
fun providesOkHttpClientInstance(certPinner: CertificatePinner, proxy: Proxy?): OkHttpClient { fun providesOkHttpClientInstance(
certificatePinner: CertificatePinner,
proxy: Proxy?,
cache: Cache
): OkHttpClient {
val okHttpClientBuilder = OkHttpClient().newBuilder() val okHttpClientBuilder = OkHttpClient().newBuilder()
.cache(cache)
.proxy(proxy) .proxy(proxy)
.connectTimeout(25, TimeUnit.SECONDS) .connectTimeout(25, TimeUnit.SECONDS)
.readTimeout(25, TimeUnit.SECONDS) .readTimeout(25, TimeUnit.SECONDS)
@@ -70,7 +77,7 @@ object OkHttpClientModule {
.followSslRedirects(true) .followSslRedirects(true)
if (!BuildConfig.DEBUG) { if (!BuildConfig.DEBUG) {
okHttpClientBuilder.certificatePinner(certPinner) okHttpClientBuilder.certificatePinner(certificatePinner)
} }
return okHttpClientBuilder.build() 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<String> { private fun getGoogleRootCertHashes(context: Context): List<String> {
return try { return try {
val certs = getX509Certificates(context.resources.openRawResource(R.raw.google_roots_ca)) val certs = getX509Certificates(context.resources.openRawResource(R.raw.google_roots_ca))

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)