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 19eee598185bacbc02f53d93c7d01dbe8d8030a4..3b44ca8ff9d11d0e7b5c1c225e2862ee43ab1ec7 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 ffd2f35a3c479b8b2b877ac8bd7e911eef6b8002..36c0dcab6162713a502508921f011be75941e64e 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 bc38fdeb4645864bbc8185cf5aaf41ccc87b3618..0f2465ea66402bb031716da0e2a2fd7400f54f3c 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 19f944fea3daf45735143e538cd983fe9e4989c7..065ef7a3219cd5d6620159be418593fa66da928b 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 8ff3506f94025e02d4f3f002f5111ff95035fd00..4376c7dfafc58f88c517cb9c888e7389c2f471dd 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