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 5792319d2..266aafec2 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 @@ -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 = 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 = 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()) } ) } diff --git a/app/src/main/java/com/aurora/store/compose/ui/details/components/AppDetails.kt b/app/src/main/java/com/aurora/store/compose/ui/details/components/AppDetails.kt index 439f30b43..bad5378f9 100644 --- a/app/src/main/java/com/aurora/store/compose/ui/details/components/AppDetails.kt +++ b/app/src/main/java/com/aurora/store/compose/ui/details/components/AppDetails.kt @@ -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( diff --git a/app/src/main/java/com/aurora/store/data/event/BusEvent.kt b/app/src/main/java/com/aurora/store/data/event/BusEvent.kt index a37d03344..d7b56c83e 100644 --- a/app/src/main/java/com/aurora/store/data/event/BusEvent.kt +++ b/app/src/main/java/com/aurora/store/data/event/BusEvent.kt @@ -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) } diff --git a/app/src/main/java/com/aurora/store/data/installer/RootInstaller.kt b/app/src/main/java/com/aurora/store/data/installer/RootInstaller.kt index 51203f5e7..1e834d73c 100644 --- a/app/src/main/java/com/aurora/store/data/installer/RootInstaller.kt +++ b/app/src/main/java/com/aurora/store/data/installer/RootInstaller.kt @@ -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) diff --git a/app/src/main/java/com/aurora/store/data/installer/ServiceInstaller.kt b/app/src/main/java/com/aurora/store/data/installer/ServiceInstaller.kt index e354db4f0..fa305f6cf 100644 --- a/app/src/main/java/com/aurora/store/data/installer/ServiceInstaller.kt +++ b/app/src/main/java/com/aurora/store/data/installer/ServiceInstaller.kt @@ -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() } diff --git a/app/src/main/java/com/aurora/store/data/installer/SessionInstaller.kt b/app/src/main/java/com/aurora/store/data/installer/SessionInstaller.kt index 53dc2dc65..41c5d82f8 100644 --- a/app/src/main/java/com/aurora/store/data/installer/SessionInstaller.kt +++ b/app/src/main/java/com/aurora/store/data/installer/SessionInstaller.kt @@ -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 + ) ) } } diff --git a/app/src/main/java/com/aurora/store/data/installer/base/InstallerBase.kt b/app/src/main/java/com/aurora/store/data/installer/base/InstallerBase.kt index 9a13d36f8..181a61dfb 100644 --- a/app/src/main/java/com/aurora/store/data/installer/base/InstallerBase.kt +++ b/app/src/main/java/com/aurora/store/data/installer/base/InstallerBase.kt @@ -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( 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 1fd9c0231..350609cd4 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 @@ -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 + } + } +} diff --git a/app/src/main/java/com/aurora/store/data/receiver/BaseInstallerStatusReceiver.kt b/app/src/main/java/com/aurora/store/data/receiver/BaseInstallerStatusReceiver.kt index 2f5551fc0..d3b6c2dcd 100644 --- a/app/src/main/java/com/aurora/store/data/receiver/BaseInstallerStatusReceiver.kt +++ b/app/src/main/java/com/aurora/store/data/receiver/BaseInstallerStatusReceiver.kt @@ -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) 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 082a0ae81..f5a018ac5 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 @@ -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) + ) + ) } } } 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 1f4447bbf..8cf7aba45 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 @@ -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("")) val app = _app.asStateFlow() + private val _state = MutableStateFlow(defaultAppState) + val state = _state.asStateFlow() + private val _suggestions = MutableStateFlow>(emptyList()) val suggestions = _suggestions.asStateFlow() @@ -98,42 +105,23 @@ class AppDetailsViewModel @Inject constructor( private val _purchaseStatus = MutableSharedFlow() 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 { - 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)