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

Commit dee37dd6 authored by Fahim M. Choudhury's avatar Fahim M. Choudhury
Browse files

Merge branch '4235-updates-fragment-ui-issues' into 'main'

fix: resolve Updates screen's UI issues

See merge request !778
parents 4fb10348 8b195604
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