From 8b1956040683c30f8a96ab990e3362d0d45a3560 Mon Sep 17 00:00:00 2001 From: Fahim Masud Choudhury Date: Fri, 17 Apr 2026 12:22:15 +0600 Subject: [PATCH] fix(updates): stop stale Update All state Separate full list refreshes from row status updates so the Updates screen stops doing heavy work for small status changes. Update All now follows the displayed app state and only the relevant update/install jobs, which keeps the button, loading state, and scroll behavior in sync with what is actually happening. --- .../install/updates/UpdatesWorkManager.kt | 8 +- .../data/install/updates/UpdatesWorker.kt | 2 +- .../e/apps/ui/updates/UpdatesFragment.kt | 266 +++++++++++------- .../install/updates/UpdatesWorkManagerTest.kt | 4 +- .../data/install/updates/UpdatesWorkerTest.kt | 9 +- 5 files changed, 176 insertions(+), 113 deletions(-) diff --git a/app/src/main/java/foundation/e/apps/data/install/updates/UpdatesWorkManager.kt b/app/src/main/java/foundation/e/apps/data/install/updates/UpdatesWorkManager.kt index 19eee5981..3b44ca8ff 100644 --- a/app/src/main/java/foundation/e/apps/data/install/updates/UpdatesWorkManager.kt +++ b/app/src/main/java/foundation/e/apps/data/install/updates/UpdatesWorkManager.kt @@ -33,8 +33,8 @@ import java.util.concurrent.TimeUnit object UpdatesWorkManager { private const val UPDATES_WORK_NAME = "updates_work" private const val UPDATES_WORK_USER_NAME = "updates_work_user" - const val TAG = "UpdatesWorkTag" - const val USER_TAG = "UpdatesWorkUserTag" + const val TAG_WORK_PERIODIC_UPDATE = "UpdatesWorkTag" + const val TAG_WORK_USER_INITIATED_UPDATE = "UpdatesWorkUserTag" fun startUpdateAllWork(context: Context) { WorkManager.getInstance(context).enqueueUniqueWork( @@ -48,7 +48,7 @@ object UpdatesWorkManager { return OneTimeWorkRequest.Builder(UpdatesWorker::class.java).apply { setConstraints(WorkRequestConstraints.build(WorkType.UpdateOneTime)) setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) - addTag(USER_TAG) + addTag(TAG_WORK_USER_INITIATED_UPDATE) }.setInputData( Data.Builder() .putBoolean(UpdatesWorker.IS_AUTO_UPDATE, false) @@ -63,7 +63,7 @@ object UpdatesWorkManager { TimeUnit.HOURS ).apply { setConstraints(WorkRequestConstraints.build(WorkType.UpdatePeriodic)) - addTag(TAG) + addTag(TAG_WORK_PERIODIC_UPDATE) }.build() } diff --git a/app/src/main/java/foundation/e/apps/data/install/updates/UpdatesWorker.kt b/app/src/main/java/foundation/e/apps/data/install/updates/UpdatesWorker.kt index ffd2f35a3..36c0dcab6 100644 --- a/app/src/main/java/foundation/e/apps/data/install/updates/UpdatesWorker.kt +++ b/app/src/main/java/foundation/e/apps/data/install/updates/UpdatesWorker.kt @@ -94,7 +94,7 @@ class UpdatesWorker @AssistedInject constructor( suspend fun checkManualUpdateRunning(): Boolean { val workInfos = withContext(Dispatchers.IO) { - WorkManager.getInstance(context).getWorkInfosByTag(UpdatesWorkManager.USER_TAG) + WorkManager.getInstance(context).getWorkInfosByTag(UpdatesWorkManager.TAG_WORK_USER_INITIATED_UPDATE) .get() } if (workInfos.isNotEmpty()) { diff --git a/app/src/main/java/foundation/e/apps/ui/updates/UpdatesFragment.kt b/app/src/main/java/foundation/e/apps/ui/updates/UpdatesFragment.kt index bc38fdeb4..0f2465ea6 100644 --- a/app/src/main/java/foundation/e/apps/ui/updates/UpdatesFragment.kt +++ b/app/src/main/java/foundation/e/apps/ui/updates/UpdatesFragment.kt @@ -23,6 +23,7 @@ import android.view.View import androidx.appcompat.app.AlertDialog import androidx.fragment.app.activityViewModels import androidx.fragment.app.viewModels +import androidx.lifecycle.LiveData import androidx.lifecycle.flowWithLifecycle import androidx.lifecycle.lifecycleScope import androidx.navigation.findNavController @@ -31,7 +32,6 @@ import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import androidx.work.WorkInfo import androidx.work.WorkManager -import androidx.work.WorkQuery import dagger.hilt.android.AndroidEntryPoint import foundation.e.apps.R import foundation.e.apps.data.ResultSupreme @@ -41,7 +41,6 @@ import foundation.e.apps.data.enums.ResultStatus import foundation.e.apps.data.event.AppEvent import foundation.e.apps.data.event.EventBus import foundation.e.apps.data.install.download.data.DownloadProgress -import foundation.e.apps.data.install.pkg.PwaManager import foundation.e.apps.data.install.updates.UpdatesWorkManager import foundation.e.apps.data.install.workmanager.InstallWorkManager import foundation.e.apps.data.installation.model.AppInstall @@ -62,25 +61,27 @@ import foundation.e.apps.ui.utils.toast import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.filter import kotlinx.coroutines.launch -import timber.log.Timber import java.util.Locale -import javax.inject.Inject + @AndroidEntryPoint class UpdatesFragment : TimeoutFragment(R.layout.fragment_updates), ApplicationInstaller { private var _binding: FragmentUpdatesBinding? = null private val binding get() = _binding!! - @Inject - lateinit var pwaManager: PwaManager - private val updatesViewModel: UpdatesViewModel by viewModels() private val privacyInfoViewModel: PrivacyInfoViewModel by viewModels() private val appInfoFetchViewModel: AppInfoFetchViewModel by viewModels() override val mainActivityViewModel: MainActivityViewModel by activityViewModels() private val appProgressViewModel: AppProgressViewModel by viewModels() - private var isDownloadObserverAdded = false + private var updatesListAdapter: ApplicationListRVAdapter? = null + private var displayedUpdates: List = emptyList() + private var shouldScrollToTopOnNextStructuralRender = true + private var periodicUpdateWorkInfos: List = emptyList() + private var userUpdateWorkInfos: List = emptyList() + private var taggedInstallWorkInfos: List = emptyList() + private var legacyInstallWorkInfos: List = emptyList() companion object { private const val SCROLL_TO_TOP_DELAY_MILLIS = 100L @@ -89,6 +90,7 @@ class UpdatesFragment : TimeoutFragment(R.layout.fragment_updates), ApplicationI override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) _binding = FragmentUpdatesBinding.bind(view) + resetViewState() binding.button.isEnabled = false initUpdateAllButton() @@ -104,13 +106,41 @@ class UpdatesFragment : TimeoutFragment(R.layout.fragment_updates), ApplicationI } val recyclerView = binding.recyclerView - val listAdapter = findNavController().currentDestination?.id?.let { + updatesListAdapter = createUpdatesListAdapter() + + recyclerView.apply { + adapter = updatesListAdapter + layoutManager = LinearLayoutManager(view.context) + } + observeRelevantWorkStates() + observeUpdateList() + observeDownloadList() + + viewLifecycleOwner.lifecycleScope.launch { + EventBus.events.flowWithLifecycle(viewLifecycleOwner.lifecycle) + .filter { appEvent -> appEvent is AppEvent.UpdateEvent }.collectLatest { + handleUpdateEvent(it) + } + } + } + + private fun resetViewState() { + displayedUpdates = emptyList() + shouldScrollToTopOnNextStructuralRender = false + periodicUpdateWorkInfos = emptyList() + userUpdateWorkInfos = emptyList() + taggedInstallWorkInfos = emptyList() + legacyInstallWorkInfos = emptyList() + } + + private fun createUpdatesListAdapter(): ApplicationListRVAdapter? { + return findNavController().currentDestination?.id?.let { destinationId -> ApplicationListRVAdapter( this, privacyInfoViewModel, appInfoFetchViewModel, mainActivityViewModel, - it, + destinationId, viewLifecycleOwner, ) { fusedApp -> viewLifecycleOwner.lifecycleScope.launch { @@ -120,50 +150,73 @@ class UpdatesFragment : TimeoutFragment(R.layout.fragment_updates), ApplicationI } } } + } - recyclerView.apply { - adapter = listAdapter - layoutManager = LinearLayoutManager(view.context) - } - observeJobStates() - observeUpdateList(listAdapter) - - viewLifecycleOwner.lifecycleScope.launch { - EventBus.events.flowWithLifecycle(viewLifecycleOwner.lifecycle) - .filter { appEvent -> appEvent is AppEvent.UpdateEvent }.collectLatest { - handleUpdateEvent(it) - } + private fun observeUpdateList() { + updatesViewModel.updatesList.observe(viewLifecycleOwner) { appsUpdateList -> + renderStructuralUpdates(appsUpdateList) } } - private fun observeUpdateList(listAdapter: ApplicationListRVAdapter?) { - updatesViewModel.updatesList.observe(viewLifecycleOwner) { appsUpdateList -> + private fun renderStructuralUpdates(appsUpdateList: List) { + val appsToDisplay = appsUpdateList + .sortedByDescending { it.isSystemApp } + .map { it.copy() } + .toMutableList() - // Put system apps on top - val appsToDisplay = appsUpdateList.sortedByDescending { it.isSystemApp } + mainActivityViewModel.downloadList.value?.let { downloadList -> + mainActivityViewModel.updateStatusOfFusedApps(appsToDisplay, downloadList) + } - listAdapter?.setData(appsToDisplay) - if (!isDownloadObserverAdded) { - updateButtonAvailability() - observeDownloadList() - isDownloadObserverAdded = true - } + displayedUpdates = appsToDisplay + updatesListAdapter?.setData(appsToDisplay) + stopLoadingUI() + updateButtonAvailability() + scrollToTopIfNeeded() + } - stopLoadingUI() + private fun scrollToTopIfNeeded() { + if (!shouldScrollToTopOnNextStructuralRender) return - binding.recyclerView.postDelayed( - { _binding?.recyclerView?.scrollToPosition(0) }, - SCROLL_TO_TOP_DELAY_MILLIS - ) + binding.recyclerView.postDelayed( + { _binding?.recyclerView?.scrollToPosition(0) }, + SCROLL_TO_TOP_DELAY_MILLIS + ) + shouldScrollToTopOnNextStructuralRender = false + } + + private fun observeRelevantWorkStates() { + val workManager = WorkManager.getInstance(requireContext()) + observeWorkInfoList( + workManager.getWorkInfosByTagLiveData(UpdatesWorkManager.TAG_WORK_PERIODIC_UPDATE) + ) { + periodicUpdateWorkInfos = it + } + observeWorkInfoList( + workManager.getWorkInfosByTagLiveData(UpdatesWorkManager.TAG_WORK_USER_INITIATED_UPDATE) + ) { + userUpdateWorkInfos = it + } + observeWorkInfoList( + workManager.getWorkInfosByTagLiveData(InstallWorkManager.INSTALL_WORK_NAME) + ) { + taggedInstallWorkInfos = it + } + observeWorkInfoList( + workManager.getWorkInfosForUniqueWorkLiveData(InstallWorkManager.INSTALL_WORK_NAME) + ) { + legacyInstallWorkInfos = it } } - private fun observeJobStates() { - WorkManager.getInstance(requireContext()) - .getWorkInfosLiveData(WorkQuery.fromStates(WorkInfo.State.entries)) - .observe(viewLifecycleOwner) { - updateButtonAvailability() - } + private fun observeWorkInfoList( + workInfosLiveData: LiveData>, + onChanged: (List) -> Unit, + ) { + workInfosLiveData.observe(viewLifecycleOwner) { workInfos -> + onChanged(workInfos) + updateButtonAvailability() + } } private fun setButtonEnabled(isEnabled: Boolean) { @@ -171,54 +224,55 @@ class UpdatesFragment : TimeoutFragment(R.layout.fragment_updates), ApplicationI } private fun updateButtonAvailability() { - val areUpdatesAvailable = updatesViewModel.hasAnyUpdatableApp() + val areUpdatesAvailable = displayedUpdates.any { + it.status == Status.UPDATABLE || it.status == Status.INSTALLATION_ISSUE + } if (!areUpdatesAvailable) { setButtonEnabled(false) - Timber.d("update are not available disabling update all button") return } - val alreadyUpdating = updatesViewModel.hasAnyPendingAppsForUpdate() + val alreadyUpdating = displayedUpdates.any { + it.status in setOf( + Status.QUEUED, + Status.AWAITING, + Status.DOWNLOADING, + Status.DOWNLOADED, + Status.INSTALLING, + Status.BLOCKED, + ) + } if (alreadyUpdating) { - Timber.d("update are in progress disabling update all button") setButtonEnabled(false) return } - val noUpdateJobIsRunning = WorkManager.getInstance(requireContext()) - .getWorkInfos( - WorkQuery.fromTags( - UpdatesWorkManager.TAG, - UpdatesWorkManager.USER_TAG - ) - ).get() - .none { it.state == WorkInfo.State.RUNNING } + val noUpdateJobIsRunning = !hasBlockingUpdateWork() - val noInstallJobIsRunning = !hasActiveInstallWork() + val noInstallJobIsRunning = !hasActiveRelevantWork( + taggedInstallWorkInfos + legacyInstallWorkInfos + ) - Timber.d("no update jobs are running : $noUpdateJobIsRunning") - Timber.d("no install jobs are running : $noInstallJobIsRunning") setButtonEnabled(noUpdateJobIsRunning && noInstallJobIsRunning) } - private fun hasActiveInstallWork(): Boolean { - val workManager = WorkManager.getInstance(requireContext()) - val isActive: (WorkInfo) -> Boolean = { info -> - info.state == WorkInfo.State.ENQUEUED || info.state == WorkInfo.State.RUNNING + private fun hasBlockingUpdateWork(): Boolean { + val hasRunningPeriodicUpdateWork = periodicUpdateWorkInfos.any { + it.state == WorkInfo.State.RUNNING } + val hasBlockingUserUpdateWork = userUpdateWorkInfos.any { + it.state == WorkInfo.State.ENQUEUED || + it.state == WorkInfo.State.RUNNING || + it.state == WorkInfo.State.BLOCKED + } + return hasRunningPeriodicUpdateWork || hasBlockingUserUpdateWork + } - // tag-based, per-package unique work names - val hasActiveTaggedInstall = workManager - .getWorkInfosByTag(InstallWorkManager.INSTALL_WORK_NAME) - .get() - .any(isActive) - - // single unique work name used by older app versions - val hasActiveLegacyInstall = workManager - .getWorkInfosForUniqueWork(InstallWorkManager.INSTALL_WORK_NAME) - .get() - .any(isActive) - - return hasActiveTaggedInstall || hasActiveLegacyInstall + private fun hasActiveRelevantWork(workInfos: List): Boolean { + return workInfos.any { + it.state == WorkInfo.State.ENQUEUED || + it.state == WorkInfo.State.RUNNING || + it.state == WorkInfo.State.BLOCKED + } } private fun handleUpdateEvent(appEvent: AppEvent) { @@ -246,6 +300,7 @@ class UpdatesFragment : TimeoutFragment(R.layout.fragment_updates), ApplicationI (event.otherPayload as AppInstall).name ) ) + return } } @@ -268,7 +323,7 @@ class UpdatesFragment : TimeoutFragment(R.layout.fragment_updates), ApplicationI override fun onTimeout( exception: Exception, predefinedDialog: AlertDialog.Builder - ): AlertDialog.Builder? { + ): AlertDialog.Builder { return predefinedDialog.apply { if (exception is GPlayException) { setMessage(R.string.timeout_desc_gplay) @@ -284,7 +339,7 @@ class UpdatesFragment : TimeoutFragment(R.layout.fragment_updates), ApplicationI override fun onSignInError( exception: GPlayLoginException, predefinedDialog: AlertDialog.Builder - ): AlertDialog.Builder? { + ): AlertDialog.Builder { return predefinedDialog.apply { setNegativeButton(R.string.open_settings) { _, _ -> openSettings() @@ -295,7 +350,7 @@ class UpdatesFragment : TimeoutFragment(R.layout.fragment_updates), ApplicationI override fun onDataLoadError( exception: Exception, predefinedDialog: AlertDialog.Builder - ): AlertDialog.Builder? { + ): AlertDialog.Builder { return predefinedDialog.apply { if (exception is GPlayException) { setNegativeButton(R.string.open_settings) { _, _ -> @@ -307,21 +362,21 @@ class UpdatesFragment : TimeoutFragment(R.layout.fragment_updates), ApplicationI override fun loadData(authObjectList: List) { if (updatesViewModel.haveSourcesChanged()) { + shouldScrollToTopOnNextStructuralRender = true + setButtonEnabled(false) showLoadingUI() updatesViewModel.loadUpdates() - updateButtonAvailability() } } private fun initUpdateAllButton() { binding.button.setOnClickListener { + setButtonEnabled(false) UpdatesWorkManager.startUpdateAllWork(requireContext()) - updateButtonAvailability() } } override fun showLoadingUI() { - updateButtonAvailability() binding.noUpdates.visibility = View.GONE binding.progressBar.visibility = View.VISIBLE binding.recyclerView.visibility = View.INVISIBLE @@ -340,20 +395,35 @@ class UpdatesFragment : TimeoutFragment(R.layout.fragment_updates), ApplicationI binding.recyclerView.visibility = View.INVISIBLE } - override fun onResume() { - super.onResume() + private fun observeDownloadProgress() { + appProgressViewModel.downloadProgress.removeObservers(viewLifecycleOwner) appProgressViewModel.downloadProgress.observe(viewLifecycleOwner) { updateProgressOfDownloadingItems(binding.recyclerView, it) } } + override fun onResume() { + super.onResume() + observeDownloadProgress() + } + private fun observeDownloadList() { mainActivityViewModel.downloadList.observe(viewLifecycleOwner) { list -> - val appList = updatesViewModel.updatesList.value?.toMutableList() ?: emptyList() - appList.let { - mainActivityViewModel.updateStatusOfFusedApps(appList, list) + if (displayedUpdates.isEmpty()) { + return@observe + } + + val updatedApps = displayedUpdates.map { it.copy() }.toMutableList() + mainActivityViewModel.updateStatusOfFusedApps(updatedApps, list) + + if (updatedApps == displayedUpdates) { + updateButtonAvailability() + return@observe } - updatesViewModel.updatesList.apply { value = appList } + + displayedUpdates = updatedApps + updatesListAdapter?.setData(updatedApps) + updateButtonAvailability() } } @@ -361,22 +431,20 @@ class UpdatesFragment : TimeoutFragment(R.layout.fragment_updates), ApplicationI recyclerView: RecyclerView, downloadProgress: DownloadProgress ) { - val adapter = recyclerView.adapter as ApplicationListRVAdapter + val adapter = recyclerView.adapter as? ApplicationListRVAdapter ?: return viewLifecycleOwner.lifecycleScope.launch { - adapter.currentList.forEach { fusedApp -> + adapter.currentList.forEachIndexed { index, fusedApp -> if (fusedApp.status !in Status.downloadStatuses) { - return@forEach + return@forEachIndexed } - val progress = - appProgressViewModel.calculateProgress(fusedApp, downloadProgress) + val progress = appProgressViewModel.calculateProgress(fusedApp, downloadProgress) if (progress == -1) { - return@forEach + return@forEachIndexed } - val viewHolder = recyclerView.findViewHolderForAdapterPosition( - adapter.currentList.indexOf(fusedApp) - ) - viewHolder?.let { + + val viewHolder = recyclerView.findViewHolderForAdapterPosition(index) + if (viewHolder != null) { (viewHolder as ApplicationListRVAdapter.ViewHolder).binding.installButton.text = String.format(Locale.getDefault(), "%d%%", progress) } @@ -385,8 +453,8 @@ class UpdatesFragment : TimeoutFragment(R.layout.fragment_updates), ApplicationI } override fun onDestroyView() { - mainActivityViewModel.downloadList.removeObservers(viewLifecycleOwner) - isDownloadObserverAdded = false + resetViewState() + updatesListAdapter = null super.onDestroyView() _binding = null } diff --git a/app/src/test/java/foundation/e/apps/data/install/updates/UpdatesWorkManagerTest.kt b/app/src/test/java/foundation/e/apps/data/install/updates/UpdatesWorkManagerTest.kt index 19f944fea..065ef7a32 100644 --- a/app/src/test/java/foundation/e/apps/data/install/updates/UpdatesWorkManagerTest.kt +++ b/app/src/test/java/foundation/e/apps/data/install/updates/UpdatesWorkManagerTest.kt @@ -66,7 +66,7 @@ class UpdatesWorkManagerTest { val workInfo = getActiveUniqueWorkInfo("updates_work_user") val workSpec = getWorkSpec(workInfo.id) - assertThat(workInfo.tags).contains(UpdatesWorkManager.USER_TAG) + assertThat(workInfo.tags).contains(UpdatesWorkManager.TAG_WORK_USER_INITIATED_UPDATE) assertThat(workSpec.input.getBoolean(UpdatesWorker.IS_AUTO_UPDATE, true)).isFalse() assertThat(workSpec.constraints.requiredNetworkType).isEqualTo(NetworkType.CONNECTED) assertThat(workSpec.expedited).isTrue() @@ -95,7 +95,7 @@ class UpdatesWorkManagerTest { val workInfo = getActiveUniqueWorkInfo("updates_work") val workSpec = getWorkSpec(workInfo.id) - assertThat(workInfo.tags).contains(UpdatesWorkManager.TAG) + assertThat(workInfo.tags).contains(UpdatesWorkManager.TAG_WORK_PERIODIC_UPDATE) assertThat(workSpec.intervalDuration).isEqualTo(TimeUnit.HOURS.toMillis(6)) assertThat(workSpec.constraints.requiresBatteryNotLow()).isTrue() assertThat(workSpec.constraints.requiredNetworkType).isEqualTo(NetworkType.CONNECTED) diff --git a/app/src/test/java/foundation/e/apps/data/install/updates/UpdatesWorkerTest.kt b/app/src/test/java/foundation/e/apps/data/install/updates/UpdatesWorkerTest.kt index 8ff3506f9..4376c7dfa 100644 --- a/app/src/test/java/foundation/e/apps/data/install/updates/UpdatesWorkerTest.kt +++ b/app/src/test/java/foundation/e/apps/data/install/updates/UpdatesWorkerTest.kt @@ -89,11 +89,6 @@ class UpdatesWorkerTest { val appInstallationFacade = mock() val blockedAppRepository = mock() val systemAppsUpdatesRepository = mock() - val appPreferencesRepository = createAppPreferencesRepository( - shouldShowUpdateNotification = true, - isAutomaticInstallEnabled = true, - isOnlyUnmeteredNetworkEnabled = false - ) val authData = AuthData(email = "user@example.com") val applications = listOf() @@ -374,7 +369,7 @@ class UpdatesWorkerTest { .setConstraints( Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build() ) - .addTag(UpdatesWorkManager.USER_TAG) + .addTag(UpdatesWorkManager.TAG_WORK_USER_INITIATED_UPDATE) .build() WorkManager.getInstance(appContext).enqueue(request).result.get() @@ -1024,7 +1019,7 @@ class UpdatesWorkerTest { override val tocStatus: StateFlow = tocStatusState override val tosVersion: StateFlow = tosVersionState - suspend fun destroyCredentials() = Unit + fun destroyCredentials() = Unit override suspend fun awaitTocStatus(): Boolean = tocStatusState.value -- GitLab