compose: details: Simplify and fix showing app states

Signed-off-by: Aayush Gupta <aayushgupta219@gmail.com>
This commit is contained in:
Aayush Gupta
2025-05-22 23:57:49 +08:00
parent 4c479e060b
commit cb216f6c01
11 changed files with 166 additions and 138 deletions

View File

@@ -73,9 +73,9 @@ import com.aurora.store.compose.ui.details.components.AppTags
import com.aurora.store.compose.ui.details.components.AppTesting
import com.aurora.store.compose.ui.dev.DevProfileScreen
import com.aurora.store.data.installer.AppInstaller
import com.aurora.store.data.model.AppState
import com.aurora.store.data.model.Report
import com.aurora.store.data.model.Scores
import com.aurora.store.data.room.download.Download
import com.aurora.store.util.PackageUtil
import com.aurora.store.util.PackageUtil.PACKAGE_NAME_GMS
import com.aurora.store.util.ShortcutManagerUtil
@@ -94,13 +94,12 @@ fun AppDetailsScreen(
val context = LocalContext.current
val app by viewModel.app.collectAsStateWithLifecycle()
val state by viewModel.state.collectAsStateWithLifecycle()
val featuredReviews by viewModel.featuredReviews.collectAsStateWithLifecycle()
val favorite by viewModel.favourite.collectAsStateWithLifecycle()
val exodusReport by viewModel.exodusReport.collectAsStateWithLifecycle()
val dataSafetyReport by viewModel.dataSafetyReport.collectAsStateWithLifecycle()
val plexusScores by viewModel.plexusScores.collectAsStateWithLifecycle()
val download by viewModel.download.collectAsStateWithLifecycle()
val installProgress by viewModel.installProgress.collectAsStateWithLifecycle()
val suggestions by viewModel.suggestions.collectAsStateWithLifecycle()
LaunchedEffect(key1 = Unit) { viewModel.fetchAppDetails(packageName) }
@@ -117,12 +116,10 @@ fun AppDetailsScreen(
suggestions = suggestions,
isFavorite = favorite,
isAnonymous = viewModel.authProvider.isAnonymous,
download = download,
installProgress = installProgress,
state = state,
plexusScores = plexusScores,
dataSafetyReport = dataSafetyReport,
exodusReport = exodusReport,
hasValidUpdate = viewModel.hasValidUpdate,
onNavigateUp = onNavigateUp,
onNavigateToAppDetails = onNavigateToAppDetails,
onDownload = { viewModel.purchase(this) },
@@ -190,12 +187,10 @@ private fun ScreenContentApp(
suggestions: List<App> = emptyList(),
isFavorite: Boolean = false,
isAnonymous: Boolean = true,
download: Download? = null,
installProgress: Float? = null,
state: AppState = AppState.Unavailable,
plexusScores: Scores? = null,
dataSafetyReport: DataSafetyReport? = null,
exodusReport: Report? = null,
hasValidUpdate: Boolean = false,
onNavigateUp: () -> Unit = {},
onNavigateToAppDetails: (packageName: String) -> Unit = {},
onDownload: () -> Unit = {},
@@ -248,13 +243,11 @@ private fun ScreenContentApp(
ScreenContentAppMainPane(
app = app,
featuredReviews = featuredReviews,
download = download,
installProgress = installProgress,
state = state,
isAnonymous = isAnonymous,
plexusScores = plexusScores,
dataSafetyReport = dataSafetyReport,
exodusReport = exodusReport,
hasValidUpdate = hasValidUpdate,
onNavigateUp = onNavigateUp,
onNavigateToScreen = { screen -> showExtraPane(screen) },
onDownload = onDownload,
@@ -316,13 +309,11 @@ private fun ScreenContentApp(
private fun ScreenContentAppMainPane(
app: App,
featuredReviews: List<Review> = emptyList(),
download: Download?,
installProgress: Float?,
state: AppState = AppState.Unavailable,
isAnonymous: Boolean,
plexusScores: Scores?,
dataSafetyReport: DataSafetyReport?,
exodusReport: Report?,
hasValidUpdate: Boolean,
onNavigateUp: () -> Unit,
onNavigateToScreen: (screen: Screen) -> Unit,
onDownload: () -> Unit,
@@ -332,12 +323,11 @@ private fun ScreenContentAppMainPane(
onTestingSubscriptionChange: (subscribe: Boolean) -> Unit,
menuActions: @Composable (RowScope.() -> Unit) = {}
) {
val context = LocalContext.current
@Composable
fun SetupAppActions() {
when {
download?.isRunning == true -> {
when (state) {
is AppState.Downloading -> {
AppActions(
primaryActionDisplayName = stringResource(R.string.action_open),
secondaryActionDisplayName = stringResource(R.string.action_cancel),
@@ -346,7 +336,7 @@ private fun ScreenContentAppMainPane(
)
}
app.isInstalled && hasValidUpdate -> {
is AppState.Updatable -> {
AppActions(
primaryActionDisplayName = stringResource(R.string.action_update),
secondaryActionDisplayName = stringResource(R.string.action_uninstall),
@@ -355,7 +345,7 @@ private fun ScreenContentAppMainPane(
)
}
app.isInstalled -> {
is AppState.Installed -> {
AppActions(
primaryActionDisplayName = stringResource(R.string.action_open),
secondaryActionDisplayName = stringResource(R.string.action_uninstall),
@@ -365,7 +355,7 @@ private fun ScreenContentAppMainPane(
}
else -> {
val primaryActionName = if (PackageUtil.isArchived(context, app.packageName)) {
val primaryActionName = if (state is AppState.Archived) {
stringResource(R.string.action_unarchive)
} else {
if (app.isFree) stringResource(R.string.action_install) else app.price
@@ -394,20 +384,12 @@ private fun ScreenContentAppMainPane(
.padding(dimensionResource(R.dimen.padding_medium)),
verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.margin_medium))
) {
val isDownloading = download != null && download.isRunning
val isInstalling = installProgress != null
val progress = when {
isDownloading -> download?.progress?.toFloat() ?: 0F
isInstalling -> installProgress ?: 0F
else -> 0F
}
AppDetails(
app = app,
inProgress = isDownloading || isInstalling,
progress = progress,
inProgress = state.inProgress(),
progress = state.progress(),
onNavigateToDetailsDevProfile = { onNavigateToScreen(Screen.DevProfile(it)) },
hasValidUpdate = hasValidUpdate
isUpdatable = state is AppState.Updatable
)
SetupAppActions()
@@ -533,7 +515,6 @@ private fun AppDetailsScreenPreview(@PreviewParameter(AppPreviewProvider::class)
ScreenContentApp(
app = app,
isAnonymous = false,
hasValidUpdate = false,
suggestions = List(10) { app.copy(id = Random.nextInt()) }
)
}

View File

@@ -40,7 +40,7 @@ import com.aurora.store.util.PackageUtil
* @param progress Ongoing progress percentage out of 100, for e.g. 50.0
* @param inProgress Whether there is some ongoing progress related to the app
* @param onNavigateToDetailsDevProfile Callback when the developer name is tapped
* @param hasValidUpdate Whether the app has a valid update available
* @param isUpdatable Whether the app has a valid update available
*/
@Composable
fun AppDetails(
@@ -48,7 +48,7 @@ fun AppDetails(
progress: Float = 0F,
inProgress: Boolean = false,
onNavigateToDetailsDevProfile: (developerName: String) -> Unit = {},
hasValidUpdate: Boolean = false,
isUpdatable: Boolean = false,
) {
val context = LocalContext.current
@@ -76,7 +76,7 @@ fun AppDetails(
color = MaterialTheme.colorScheme.primary
)
Text(
text = if (!hasValidUpdate) {
text = if (!isUpdatable) {
stringResource(R.string.version, app.versionName, app.versionCode)
} else {
stringResource(

View File

@@ -32,15 +32,18 @@ sealed class AuthEvent : Event() {
data class GoogleLogin(val success: Boolean, val email: String, val token: String) : AuthEvent()
}
sealed class InstallerEvent : Event() {
lateinit var extra: String
lateinit var error: String
open class InstallerEvent(open val packageName: String) : Event() {
data class Installed(override val packageName: String) : InstallerEvent(packageName)
data class Uninstalled(override val packageName: String) : InstallerEvent(packageName)
var progress: Int = -1
data class Installing(
override val packageName: String,
val progress: Float = 0.0F
) : InstallerEvent(packageName)
data class Installed(val packageName: String) : InstallerEvent()
data class Uninstalled(val packageName: String) : InstallerEvent()
data class Installing(val packageName: String) : InstallerEvent()
data class Cancelled(val packageName: String) : InstallerEvent()
data class Failed(val packageName: String) : InstallerEvent()
data class Failed(
override val packageName: String,
val error: String? = null,
val extra: String? = null,
) : InstallerEvent(packageName)
}

View File

@@ -112,11 +112,12 @@ class RootInstaller @Inject constructor(
if (packageName == download?.packageName) onInstallationSuccess()
} else {
removeFromInstallQueue(packageName)
val event = InstallerEvent.Failed(packageName).apply {
this.extra = context.getString(R.string.installer_status_failure)
this.error = parseError(shellResult)
}
AuroraApp.events.send(event)
AuroraApp.events.send(
InstallerEvent.Failed(
packageName = packageName,
error = parseError(shellResult)
)
)
}
} else {
removeFromInstallQueue(packageName)

View File

@@ -230,11 +230,7 @@ class ServiceInstaller @Inject constructor(
try {
when (returnCode) {
PackageInstaller.STATUS_SUCCESS -> {
AuroraApp.events.send(
InstallerEvent.Uninstalled(packageName).apply {
this.extra = context.getString(R.string.action_uninstall_success)
}
)
AuroraApp.events.send(InstallerEvent.Uninstalled(packageName = packageName))
}
else -> postError(packageName, getErrorString(context, returnCode), extra)
@@ -253,11 +249,7 @@ class ServiceInstaller @Inject constructor(
try {
when (returnCode) {
PackageInstaller.STATUS_SUCCESS -> {
AuroraApp.events.send(
InstallerEvent.Installed(packageName).apply {
this.extra = context.getString(R.string.installer_status_success)
}
)
AuroraApp.events.send(InstallerEvent.Installed(packageName = packageName))
// Installation is not yet finished if this is a shared library
if (packageName == download?.packageName) onInstallationSuccess()
}

View File

@@ -86,9 +86,10 @@ class SessionInstaller @Inject constructor(
if (packageName != null && progress > 0.0) {
AuroraApp.events.send(
InstallerEvent.Installing(packageName).apply {
this.progress = (progress * 100).toInt()
}
InstallerEvent.Installing(
packageName = packageName,
progress = progress
)
)
}
}

View File

@@ -91,14 +91,14 @@ abstract class InstallerBase(private val context: Context) : IInstaller {
}
open fun postError(packageName: String, error: String?, extra: String?) {
Log.e(TAG, "Service Error :$error")
val event = InstallerEvent.Failed(packageName).apply {
this.error = error ?: ""
this.extra = extra ?: ""
}
AuroraApp.events.send(event)
Log.e(TAG, "Installer Error :$error")
AuroraApp.events.send(
InstallerEvent.Failed(
packageName = packageName,
error = error,
extra = extra
)
)
}
fun getFiles(

View File

@@ -44,3 +44,38 @@ sealed class AuthState {
data class PendingAccountManager(val email: String, val token: String) : AuthState()
data class Failed(val status: String) : AuthState()
}
/**
* Possible states of an app to show appropriate actions on UI
*/
sealed class AppState {
data class Downloading(
val progress: Float,
val speed: Long,
val timeRemaining: Long
) : AppState()
data class Installing(val progress: Float) : AppState()
data object Installed : AppState()
data object Archived : AppState()
data object Updatable : AppState()
data object Unavailable : AppState()
/**
* Whether there is some sort of ongoing process related to the app
*/
fun inProgress(): Boolean {
return this is Downloading || this is Installing
}
/**
* Progress of the process related to the app; 0 otherwise
*/
fun progress(): Float {
return when (this) {
is Downloading -> progress
is Installing -> progress
else -> 0F
}
}
}

View File

@@ -132,26 +132,17 @@ abstract class BaseInstallerStatusReceiver : BroadcastReceiver() {
promptUser(context, intent)
}
open fun postStatus(status: Int, packageName: String?, extra: String?, context: Context) {
open fun postStatus(status: Int, packageName: String, extra: String?, context: Context) {
val event = when (status) {
PackageInstaller.STATUS_SUCCESS -> {
InstallerEvent.Installed(packageName!!).apply {
this.extra = context.getString(R.string.installer_status_success)
}
}
PackageInstaller.STATUS_SUCCESS -> InstallerEvent.Installed(
packageName = packageName
)
PackageInstaller.STATUS_FAILURE_ABORTED -> {
InstallerEvent.Cancelled(packageName!!).apply {
this.extra = InstallerBase.getErrorString(context, status)
}
}
else -> {
InstallerEvent.Failed(packageName!!).apply {
this.error = InstallerBase.getErrorString(context, status)
this.extra = extra ?: ""
}
}
else -> InstallerEvent.Failed(
packageName = packageName,
error = InstallerBase.getErrorString(context, status),
extra = extra
)
}
AuroraApp.events.send(event)

View File

@@ -222,11 +222,14 @@ class DownloadWorker @AssistedInject constructor(
else -> {
notifyStatus(DownloadStatus.FAILED)
AuroraApp.events.send(InstallerEvent.Failed(download.packageName).apply {
extra =
exception.message ?: context.getString(R.string.download_failed)
error = exception.stackTraceToString()
})
AuroraApp.events.send(
InstallerEvent.Failed(
packageName = download.packageName,
error = exception.stackTraceToString(),
extra = exception.message
?: context.getString(R.string.download_failed)
)
)
}
}
}

View File

@@ -7,7 +7,6 @@
package com.aurora.store.viewmodel.details
import android.content.Context
import android.content.pm.PackageInstaller
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
@@ -20,8 +19,11 @@ 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
import com.aurora.store.AuroraApp
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.ExodusReport
import com.aurora.store.data.model.PlexusReport
import com.aurora.store.data.model.Report
@@ -37,14 +39,16 @@ 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.channels.awaitClose
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.callbackFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
@@ -71,6 +75,9 @@ class AppDetailsViewModel @Inject constructor(
private val _app = MutableStateFlow<App?>(App(""))
val app = _app.asStateFlow()
private val _state = MutableStateFlow(defaultAppState)
val state = _state.asStateFlow()
private val _suggestions = MutableStateFlow<List<App>>(emptyList())
val suggestions = _suggestions.asStateFlow()
@@ -98,42 +105,23 @@ class AppDetailsViewModel @Inject constructor(
private val _purchaseStatus = MutableSharedFlow<Boolean>()
val purchaseStatus = _purchaseStatus.asSharedFlow()
val download = combine(app, downloadHelper.downloadsList) { a, list ->
private val download = combine(app, downloadHelper.downloadsList) { a, list ->
if (a?.packageName.isNullOrBlank()) return@combine null
list.find { d -> d.packageName == a?.packageName }
}.stateIn(viewModelScope, SharingStarted.Eagerly, null)
// TODO: Save sessionId to downloads to monitor packageInstall progress
val installProgress = callbackFlow<Float?> {
val packageInstaller = context.packageManager.packageInstaller
val callback = object : PackageInstaller.SessionCallback() {
override fun onActiveChanged(p0: Int, p1: Boolean) {}
override fun onBadgingChanged(p0: Int) {}
override fun onCreated(p0: Int) {
// trySend(0F)
}
override fun onFinished(p0: Int, p1: Boolean) {
// trySend(null)
}
override fun onProgressChanged(p0: Int, p1: Float) {
// trySend(p1)
}
}
private val isInstalled: Boolean
get() = PackageUtil.isInstalled(context, app.value!!.packageName)
packageInstaller.registerSessionCallback(callback)
awaitClose { packageInstaller.unregisterSessionCallback(callback) }
}.stateIn(viewModelScope, SharingStarted.Eagerly, null)
private val isUpdatable: Boolean
get() = PackageUtil.isUpdatable(context, app.value!!.packageName, app.value!!.versionCode)
private val isArchived: Boolean
get() = PackageUtil.isArchived(context, app.value!!.packageName)
private val isExtendedUpdateEnabled: Boolean
get() = Preferences.getBoolean(context, PREFERENCE_UPDATES_EXTENDED)
private val isUpdatable: Boolean
get() = PackageUtil.isUpdatable(
context,
app.value!!.packageName,
app.value!!.versionCode
)
private val hasValidCerts: Boolean
get() = app.value!!.certificateSetList.any {
it.certificateSet in CertUtil.getEncodedCertificateHashes(
@@ -142,9 +130,19 @@ class AppDetailsViewModel @Inject constructor(
)
}
val hasValidUpdate: Boolean
private val hasValidUpdate: Boolean
get() = (isUpdatable && hasValidCerts) || (isUpdatable && isExtendedUpdateEnabled)
private val defaultAppState: AppState
get() = when {
isInstalled -> if (hasValidUpdate) AppState.Updatable else AppState.Installed
else -> if (isArchived) AppState.Archived else AppState.Unavailable
}
init {
observeAppState()
}
fun fetchAppDetails(packageName: String) {
viewModelScope.launch(Dispatchers.IO) {
try {
@@ -170,18 +168,6 @@ class AppDetailsViewModel @Inject constructor(
}
}
private fun fetchFeaturedReviews(packageName: String) {
viewModelScope.launch(Dispatchers.IO) {
try {
_featuredReviews.value = reviewsHelper.getReviewSummary(packageName)
} catch (exception: Exception) {
Log.e(TAG, "Failed to fetch featured app reviews", exception)
_featuredReviews.value = emptyList()
}
}
}
fun updateTestingProgramStatus(packageName: String, subscribe: Boolean) {
viewModelScope.launch(Dispatchers.IO) {
try {
@@ -232,7 +218,7 @@ class AppDetailsViewModel @Inject constructor(
if (files.isNotEmpty()) download(app.copy(fileList = files.toMutableList()))
} catch (exception: Exception) {
_purchaseStatus.emit(false)
Log.e(TAG, "Failed to purchase the app ", exception)
Log.e(TAG, "Failed to purchase the app", exception)
}
}
}
@@ -261,6 +247,41 @@ class AppDetailsViewModel @Inject constructor(
}
}
private fun observeAppState() {
AuroraApp.events.installerEvent
.filter { it.packageName == app.value?.packageName }
.onEach { event ->
_state.value = when {
event is InstallerEvent.Installing -> AppState.Installing(event.progress)
else -> defaultAppState
}
}.launchIn(viewModelScope)
download.filterNotNull().onEach {
_state.value = when {
it.isRunning -> AppState.Downloading(
it.progress.toFloat(),
it.speed,
it.timeRemaining
)
else -> defaultAppState
}
}.launchIn(viewModelScope)
}
private fun fetchFeaturedReviews(packageName: String) {
viewModelScope.launch(Dispatchers.IO) {
try {
_featuredReviews.value = reviewsHelper.getReviewSummary(packageName)
} catch (exception: Exception) {
Log.e(TAG, "Failed to fetch featured app reviews", exception)
_featuredReviews.value = emptyList()
}
}
}
private fun fetchFavourite(packageName: String) {
viewModelScope.launch(Dispatchers.IO) {
_favourite.value = favouriteDao.isFavourite(packageName)