Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Verified Commit 8b195604 authored by Fahim M. Choudhury's avatar Fahim M. Choudhury
Browse files

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.
parent 4fb10348
Loading
Loading
Loading
Loading
Loading
+4 −4
Original line number Diff line number Diff line
@@ -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()
    }

+1 −1
Original line number Diff line number Diff line
@@ -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()) {
+167 −99
Original line number Diff line number Diff line
@@ -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<Application> = emptyList()
    private var shouldScrollToTopOnNextStructuralRender = true
    private var periodicUpdateWorkInfos: List<WorkInfo> = emptyList()
    private var userUpdateWorkInfos: List<WorkInfo> = emptyList()
    private var taggedInstallWorkInfos: List<WorkInfo> = emptyList()
    private var legacyInstallWorkInfos: List<WorkInfo> = 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,48 +150,71 @@ 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<Application>) {
        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) {
        displayedUpdates = appsToDisplay
        updatesListAdapter?.setData(appsToDisplay)
        stopLoadingUI()
        updateButtonAvailability()
                observeDownloadList()
                isDownloadObserverAdded = true
        scrollToTopIfNeeded()
    }

            stopLoadingUI()
    private fun scrollToTopIfNeeded() {
        if (!shouldScrollToTopOnNextStructuralRender) return

        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) {
    private fun observeWorkInfoList(
        workInfosLiveData: LiveData<List<WorkInfo>>,
        onChanged: (List<WorkInfo>) -> Unit,
    ) {
        workInfosLiveData.observe(viewLifecycleOwner) { workInfos ->
            onChanged(workInfos)
            updateButtonAvailability()
        }
    }
@@ -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<WorkInfo>): 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<AuthObject>) {
        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
            }
            updatesViewModel.updatesList.apply { value = appList }

            val updatedApps = displayedUpdates.map { it.copy() }.toMutableList()
            mainActivityViewModel.updateStatusOfFusedApps(updatedApps, list)

            if (updatedApps == displayedUpdates) {
                updateButtonAvailability()
                return@observe
            }

            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
    }
+2 −2
Original line number Diff line number Diff line
@@ -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)
+2 −7
Original line number Diff line number Diff line
@@ -89,11 +89,6 @@ class UpdatesWorkerTest {
        val appInstallationFacade = mock<AppInstallationFacade>()
        val blockedAppRepository = mock<BlockedAppRepository>()
        val systemAppsUpdatesRepository = mock<SystemAppsUpdatesRepository>()
        val appPreferencesRepository = createAppPreferencesRepository(
            shouldShowUpdateNotification = true,
            isAutomaticInstallEnabled = true,
            isOnlyUnmeteredNetworkEnabled = false
        )
        val authData = AuthData(email = "user@example.com")
        val applications = listOf<Application>()

@@ -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<Boolean> = tocStatusState
        override val tosVersion: StateFlow<String> = tosVersionState

        suspend fun destroyCredentials() = Unit
        fun destroyCredentials() = Unit

        override suspend fun awaitTocStatus(): Boolean = tocStatusState.value