From 3d8f13786aedd211ab67c1f92ca03c689cb0cefc Mon Sep 17 00:00:00 2001 From: Fahim Masud Choudhury Date: Tue, 5 May 2026 19:43:26 +0600 Subject: [PATCH 1/3] fix(updates): preserve app data in manual update chains fix(updates): fix update getting skipped for large number of updates Fixes the buggy behaviour by introducing a chunk-based mechanism so that instead of trying to enqueue all the app updates at once, App Lounge now batches the updates and process them chunk by chunk for manual Update All action. --- .../e/apps/data/install/AppManagerImpl.kt | 5 +- .../data/install/core/InstallationEnqueuer.kt | 2 + .../install/core/helper/PreEnqueueChecker.kt | 10 +- .../updates/ManualUpdateChainSnapshot.kt | 40 +++ .../install/updates/ManualUpdateChainStore.kt | 79 ++++++ .../install/updates/UpdatesWorkManager.kt | 62 ++++- .../data/install/updates/UpdatesWorker.kt | 203 +++++++++++--- .../data/updates/UpdatesManagerRepository.kt | 4 +- .../e/apps/ui/updates/UpdatesFragment.kt | 34 ++- .../updates/ManualUpdateChainStoreTest.kt | 165 +++++++++++ .../install/updates/UpdatesWorkManagerTest.kt | 32 ++- .../data/install/updates/UpdatesWorkerTest.kt | 260 +++++++++++++++++- .../e/apps/ui/updates/UpdatesFragmentTest.kt | 93 +++++++ 13 files changed, 927 insertions(+), 62 deletions(-) create mode 100644 app/src/main/java/foundation/e/apps/data/install/updates/ManualUpdateChainSnapshot.kt create mode 100644 app/src/main/java/foundation/e/apps/data/install/updates/ManualUpdateChainStore.kt create mode 100644 app/src/test/java/foundation/e/apps/data/install/updates/ManualUpdateChainStoreTest.kt create mode 100644 app/src/test/java/foundation/e/apps/ui/updates/UpdatesFragmentTest.kt diff --git a/app/src/main/java/foundation/e/apps/data/install/AppManagerImpl.kt b/app/src/main/java/foundation/e/apps/data/install/AppManagerImpl.kt index 34aed8ea7..e2b9d9fde 100644 --- a/app/src/main/java/foundation/e/apps/data/install/AppManagerImpl.kt +++ b/app/src/main/java/foundation/e/apps/data/install/AppManagerImpl.kt @@ -51,6 +51,7 @@ import javax.inject.Inject import javax.inject.Named import javax.inject.Singleton import com.aurora.gplayapi.data.models.PlayFile as AuroraFile + @Singleton class AppManagerImpl @Inject constructor( @Named("cacheDir") private val cacheDir: String, @@ -65,7 +66,6 @@ class AppManagerImpl @Inject constructor( @ApplicationContext private val context: Context, private val fDroidRepository: FDroidRepository ) : AppManager { - @Inject lateinit var contentRatingDao: ContentRatingDao @@ -88,8 +88,9 @@ class AppManagerImpl @Inject constructor( override suspend fun addDownload(appInstall: AppInstall): Boolean { val existingFusedDownload = getDownloadById(appInstall) + val isInstallWorkRunning = isInstallWorkRunning(existingFusedDownload, appInstall) val canAddDownload = when { - isInstallWorkRunning(existingFusedDownload, appInstall) -> false + isInstallWorkRunning -> false // We don't want to add anything if it already exists without INSTALLATION_ISSUE existingFusedDownload != null && !isStatusEligibleToInstall(existingFusedDownload) -> false else -> true diff --git a/app/src/main/java/foundation/e/apps/data/install/core/InstallationEnqueuer.kt b/app/src/main/java/foundation/e/apps/data/install/core/InstallationEnqueuer.kt index 00d7665ed..e27e1543e 100644 --- a/app/src/main/java/foundation/e/apps/data/install/core/InstallationEnqueuer.kt +++ b/app/src/main/java/foundation/e/apps/data/install/core/InstallationEnqueuer.kt @@ -30,6 +30,7 @@ import foundation.e.apps.domain.preferences.SessionRepository import kotlinx.coroutines.CancellationException import timber.log.Timber import javax.inject.Inject + class InstallationEnqueuer @Inject constructor( private val preEnqueueChecker: PreEnqueueChecker, private val appManager: AppManager, @@ -37,6 +38,7 @@ class InstallationEnqueuer @Inject constructor( private val playStoreAuthStore: PlayStoreAuthStore, private val appEventDispatcher: AppEventDispatcher, ) { + suspend fun enqueue( appInstall: AppInstall, isAnUpdate: Boolean = false, diff --git a/app/src/main/java/foundation/e/apps/data/install/core/helper/PreEnqueueChecker.kt b/app/src/main/java/foundation/e/apps/data/install/core/helper/PreEnqueueChecker.kt index 9c55e01c8..63f82bddf 100644 --- a/app/src/main/java/foundation/e/apps/data/install/core/helper/PreEnqueueChecker.kt +++ b/app/src/main/java/foundation/e/apps/data/install/core/helper/PreEnqueueChecker.kt @@ -21,7 +21,6 @@ package foundation.e.apps.data.install.core.helper import foundation.e.apps.data.application.AppManager import foundation.e.apps.data.installation.model.AppInstall import foundation.e.apps.data.installation.model.InstallationType -import timber.log.Timber import javax.inject.Inject class PreEnqueueChecker @Inject constructor( @@ -30,22 +29,17 @@ class PreEnqueueChecker @Inject constructor( private val ageLimiter: AgeLimiter, private val devicePreconditions: DevicePreconditions, ) { + suspend fun canEnqueue(appInstall: AppInstall, isAnUpdate: Boolean = false): Boolean { val hasUpdatedDownloadUrls = appInstall.type == InstallationType.PWA || downloadUrlRefresher.updateDownloadUrls(appInstall, isAnUpdate) val isDownloadAdded = hasUpdatedDownloadUrls && addDownload(appInstall) val isAgeLimitAllowed = isDownloadAdded && ageLimiter.allow(appInstall) - return isAgeLimitAllowed && devicePreconditions.canProceed(appInstall) } private suspend fun addDownload(appInstall: AppInstall): Boolean { - val isDownloadAdded = appManager.addDownload(appInstall) - if (!isDownloadAdded) { - Timber.i("Update adding ABORTED! status") - } - - return isDownloadAdded + return appManager.addDownload(appInstall) } } diff --git a/app/src/main/java/foundation/e/apps/data/install/updates/ManualUpdateChainSnapshot.kt b/app/src/main/java/foundation/e/apps/data/install/updates/ManualUpdateChainSnapshot.kt new file mode 100644 index 000000000..252650782 --- /dev/null +++ b/app/src/main/java/foundation/e/apps/data/install/updates/ManualUpdateChainSnapshot.kt @@ -0,0 +1,40 @@ +/* + * Copyright (C) 2026 e Foundation + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package foundation.e.apps.data.install.updates + +import foundation.e.apps.data.application.data.Application + +data class ManualUpdateChainSnapshot( + val chainId: String, + val packages: List, + val cursor: Int = 0, + val createdAtMillis: Long, +) + +fun buildManualUpdateChainSnapshot( + chainId: String, + applications: List, + createdAtMillis: Long, +): ManualUpdateChainSnapshot { + return ManualUpdateChainSnapshot( + chainId = chainId, + packages = applications, + createdAtMillis = createdAtMillis, + ) +} diff --git a/app/src/main/java/foundation/e/apps/data/install/updates/ManualUpdateChainStore.kt b/app/src/main/java/foundation/e/apps/data/install/updates/ManualUpdateChainStore.kt new file mode 100644 index 000000000..11a95f699 --- /dev/null +++ b/app/src/main/java/foundation/e/apps/data/install/updates/ManualUpdateChainStore.kt @@ -0,0 +1,79 @@ +/* + * Copyright (C) 2026 e Foundation + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package foundation.e.apps.data.install.updates + +import android.content.Context +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import com.google.gson.Gson +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.first +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Singleton + +private const val MANUAL_UPDATE_CHAIN_PREFERENCE_DATA_STORE_NAME = "ManualUpdateChains" +val Context.manualUpdateChainDataStore by preferencesDataStore( + MANUAL_UPDATE_CHAIN_PREFERENCE_DATA_STORE_NAME +) + +@Singleton +class ManualUpdateChainStore @Inject constructor( + @ApplicationContext + private val context: Context, + @Named("gsonCustomAdapter") + private val gson: Gson, +) { + companion object { + private val ACTIVE_CHAIN_SNAPSHOT = stringPreferencesKey("active_chain_snapshot") + } + + suspend fun readSnapshot(chainId: String): ManualUpdateChainSnapshot? { + return context.manualUpdateChainDataStore.data.first()[ACTIVE_CHAIN_SNAPSHOT] + ?.let { gson.fromJson(it, ManualUpdateChainSnapshot::class.java) } + ?.takeIf { it.chainId == chainId } + } + + suspend fun writeSnapshot(snapshot: ManualUpdateChainSnapshot) { + context.manualUpdateChainDataStore.edit { + it[ACTIVE_CHAIN_SNAPSHOT] = gson.toJson(snapshot) + } + } + + suspend fun advanceSnapshot(chainId: String, consumedCount: Int): ManualUpdateChainSnapshot? { + val snapshot = readSnapshot(chainId) ?: return null + val advancedSnapshot = snapshot.copy( + cursor = (snapshot.cursor + consumedCount).coerceAtMost(snapshot.packages.size) + ) + writeSnapshot(advancedSnapshot) + return advancedSnapshot + } + + suspend fun clearSnapshot(chainId: String) { + context.manualUpdateChainDataStore.edit { preferences -> + val storedSnapshot = preferences[ACTIVE_CHAIN_SNAPSHOT] + ?.let { gson.fromJson(it, ManualUpdateChainSnapshot::class.java) } + ?: return@edit + if (storedSnapshot.chainId == chainId) { + preferences.remove(ACTIVE_CHAIN_SNAPSHOT) + } + } + } +} 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 3b44ca8ff..5738fc4d6 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 @@ -24,6 +24,7 @@ import androidx.work.ExistingWorkPolicy import androidx.work.OneTimeWorkRequest import androidx.work.OutOfQuotaPolicy import androidx.work.PeriodicWorkRequest +import androidx.work.WorkInfo import androidx.work.WorkManager import foundation.e.apps.data.install.workmanager.WorkRequestConstraints import foundation.e.apps.data.install.workmanager.WorkType @@ -35,16 +36,69 @@ object UpdatesWorkManager { private const val UPDATES_WORK_USER_NAME = "updates_work_user" const val TAG_WORK_PERIODIC_UPDATE = "UpdatesWorkTag" const val TAG_WORK_USER_INITIATED_UPDATE = "UpdatesWorkUserTag" + const val PROGRESS_KEY_PERIODIC_PHASE = "periodic_update_phase" + const val PERIODIC_PHASE_CHECKING = "CHECKING" + const val PERIODIC_PHASE_APPLYING = "APPLYING" + const val INPUT_KEY_CHAIN_ID = "manual_update_chain_id" + const val INPUT_KEY_IS_MANUAL_CHAIN_CONTINUATION = "manual_update_chain_continuation" - fun startUpdateAllWork(context: Context) { + enum class PeriodicUpdateWorkState { + NOT_RUNNING, + CHECKING, + APPLYING, + UNKNOWN_RUNNING, + } + + fun buildPeriodicPhaseData(phase: String): Data { + return Data.Builder() + .putString(PROGRESS_KEY_PERIODIC_PHASE, phase) + .build() + } + + fun getPeriodicUpdateWorkState(workInfo: WorkInfo): PeriodicUpdateWorkState { + if (workInfo.state != WorkInfo.State.RUNNING) return PeriodicUpdateWorkState.NOT_RUNNING + + return when (workInfo.progress.getString(PROGRESS_KEY_PERIODIC_PHASE)) { + PERIODIC_PHASE_CHECKING -> PeriodicUpdateWorkState.CHECKING + PERIODIC_PHASE_APPLYING -> PeriodicUpdateWorkState.APPLYING + else -> PeriodicUpdateWorkState.UNKNOWN_RUNNING + } + } + + fun getBlockingPeriodicUpdateWorkInfo(workInfos: List): Pair? { + return workInfos.firstNotNullOfOrNull { workInfo -> + val state = getPeriodicUpdateWorkState(workInfo) + when (state) { + PeriodicUpdateWorkState.APPLYING, + PeriodicUpdateWorkState.UNKNOWN_RUNNING -> workInfo to state + PeriodicUpdateWorkState.NOT_RUNNING, + PeriodicUpdateWorkState.CHECKING -> null + } + } + } + + fun startUpdateAllWork(context: Context, chainId: String) { + val request = buildOneTimeWorkRequest(chainId, isManualChainContinuation = false) WorkManager.getInstance(context).enqueueUniqueWork( UPDATES_WORK_USER_NAME, ExistingWorkPolicy.REPLACE, - buildOneTimeWorkRequest() + request + ) + } + + fun appendUpdateAllWork(context: Context, chainId: String) { + val request = buildOneTimeWorkRequest(chainId, isManualChainContinuation = true) + WorkManager.getInstance(context).enqueueUniqueWork( + UPDATES_WORK_USER_NAME, + ExistingWorkPolicy.APPEND_OR_REPLACE, + request ) } - private fun buildOneTimeWorkRequest(): OneTimeWorkRequest { + private fun buildOneTimeWorkRequest( + chainId: String, + isManualChainContinuation: Boolean, + ): OneTimeWorkRequest { return OneTimeWorkRequest.Builder(UpdatesWorker::class.java).apply { setConstraints(WorkRequestConstraints.build(WorkType.UpdateOneTime)) setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) @@ -52,6 +106,8 @@ object UpdatesWorkManager { }.setInputData( Data.Builder() .putBoolean(UpdatesWorker.IS_AUTO_UPDATE, false) + .putString(INPUT_KEY_CHAIN_ID, chainId) + .putBoolean(INPUT_KEY_IS_MANUAL_CHAIN_CONTINUATION, isManualChainContinuation) .build() ).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 af017400c..88b181342 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 @@ -36,6 +36,7 @@ import timber.log.Timber class UpdatesWorker @AssistedInject constructor( @Assisted private val context: Context, @Assisted private val params: WorkerParameters, + private val manualUpdateChainStore: ManualUpdateChainStore, private val updatesManagerRepository: UpdatesManagerRepository, private val blockedAppRepository: BlockedAppRepository, private val systemAppsUpdatesRepository: SystemAppsUpdatesRepository, @@ -47,6 +48,7 @@ class UpdatesWorker @AssistedInject constructor( companion object { const val IS_AUTO_UPDATE = "IS_AUTO_UPDATE" + private const val MANUAL_UPDATE_CHUNK_SIZE = 15 @VisibleForTesting const val MAX_RETRY_COUNT = 10 @@ -57,31 +59,27 @@ class UpdatesWorker @AssistedInject constructor( private var automaticInstallEnabled = true private var onlyOnUnmeteredNetwork = true private var isAutoUpdate = true // indicates it is auto update or user initiated update + private var manualUpdateChainId: String? = null + private var isManualUpdateChainContinuation = false + private var skippedManualUpdateBecausePeriodicWorkIsBlocking = false private var retryCount = 0 override suspend fun doWork(): Result { return try { - isAutoUpdate = params.inputData.getBoolean(IS_AUTO_UPDATE, true) - if (isAutoUpdate && checkManualUpdateRunning()) { - return Result.success() - } - - if (isAutoUpdate) { - check(blockedAppRepository.fetchUpdateOfAppWarningList()) { - "failed to update app blocklist" + loadWorkInput() + if (isAutoUpdate) publishPeriodicPhase(UpdatesWorkManager.PERIODIC_PHASE_CHECKING) + if (shouldSkipWork()) { + Result.success() + } else { + refreshUpdatePrerequisites() + val enqueueInstall = checkForUpdates() + check(enqueueInstall == ResultStatus.OK) { + "failed to enqueue all item" } + Result.success() } - - val systemAppsUpdateTask = - systemAppsUpdatesRepository.fetchUpdatableSystemApps(forceRefresh = true) - check(systemAppsUpdateTask.isSuccess()) { "failed to fetch system apps update!" } - val enqueueInstall = checkForUpdates() - check(enqueueInstall == ResultStatus.OK) { - "failed to enqueue all item" - } - Result.success() } catch (e: Throwable) { - Timber.e(e) + Timber.e(e, "Update worker failed") Result.failure() } finally { if (shouldShowNotification && automaticInstallEnabled) { @@ -90,6 +88,35 @@ class UpdatesWorker @AssistedInject constructor( } } + private fun loadWorkInput() { + isAutoUpdate = params.inputData.getBoolean(IS_AUTO_UPDATE, true) + manualUpdateChainId = params.inputData.getString(UpdatesWorkManager.INPUT_KEY_CHAIN_ID) + isManualUpdateChainContinuation = params.inputData.getBoolean( + UpdatesWorkManager.INPUT_KEY_IS_MANUAL_CHAIN_CONTINUATION, + false, + ) + } + + private suspend fun shouldSkipWork(): Boolean { + return when { + isAutoUpdate && checkManualUpdateRunning() -> true + !isAutoUpdate && !isManualUpdateChainContinuation && checkBlockingPeriodicUpdateRunning() -> true + else -> false + } + } + + private suspend fun refreshUpdatePrerequisites() { + if (isAutoUpdate) { + check(blockedAppRepository.fetchUpdateOfAppWarningList()) { + "failed to update app blocklist" + } + } + + val systemAppsUpdateTask = + systemAppsUpdatesRepository.fetchUpdatableSystemApps(forceRefresh = true) + check(systemAppsUpdateTask.isSuccess()) { "failed to fetch system apps update!" } + } + @VisibleForTesting suspend fun checkManualUpdateRunning(): Boolean { val workInfos = @@ -97,15 +124,25 @@ class UpdatesWorker @AssistedInject constructor( WorkManager.getInstance(context).getWorkInfosByTag(UpdatesWorkManager.TAG_WORK_USER_INITIATED_UPDATE) .get() } - if (workInfos.isNotEmpty()) { - val workInfo = workInfos[0] - Timber.d("Manual update status: workInfo.state=${workInfo.state}, id=${workInfo.id}") - return when (workInfo.state) { - State.BLOCKED, State.ENQUEUED, State.RUNNING -> true - else -> false + return workInfos.any { workInfo -> + workInfo.state == State.BLOCKED || + workInfo.state == State.ENQUEUED || + workInfo.state == State.RUNNING + } + } + + @VisibleForTesting + suspend fun checkBlockingPeriodicUpdateRunning(): Boolean { + val workInfos = try { + withContext(Dispatchers.IO) { + WorkManager.getInstance(context).getWorkInfosByTag(UpdatesWorkManager.TAG_WORK_PERIODIC_UPDATE) + .get() } + } catch (exception: IllegalStateException) { + Timber.w(exception, "WorkManager unavailable during periodic blocking check") + emptyList() } - return false + return UpdatesWorkManager.getBlockingPeriodicUpdateWorkInfo(workInfos) != null } private suspend fun getUser(): User { @@ -115,20 +152,84 @@ class UpdatesWorker @AssistedInject constructor( private suspend fun checkForUpdates(): ResultStatus { loadSettings() val isConnectedToUnMeteredNetwork = isConnectedToUnMeteredNetwork(applicationContext) - val (appsNeededToUpdate, resultStatus) = loadWithRetry(emptyList(), ::getAvailableUpdates) + return if (!isAutoUpdate && manualUpdateChainId != null) { + processManualUpdateChain(manualUpdateChainId!!) + } else { + val (appsNeededToUpdate, resultStatus) = loadWithRetry(emptyList(), ::getAvailableUpdates) + + if (resultStatus != ResultStatus.OK) { + resultStatus + } else { + Timber.i("Updates found: ${appsNeededToUpdate.size}; $resultStatus") + if (isAutoUpdate && shouldShowNotification) { + handleNotification(appsNeededToUpdate.size, isConnectedToUnMeteredNetwork) + } + + val jobsEnqueued = + triggerUpdateProcessOnSettings(isConnectedToUnMeteredNetwork, appsNeededToUpdate) + if (skippedManualUpdateBecausePeriodicWorkIsBlocking) { + ResultStatus.OK + } else if (jobsEnqueued.all { it.second }) { + ResultStatus.OK + } else { + ResultStatus.UNKNOWN + } + } + } + } - if (resultStatus != ResultStatus.OK) { - return resultStatus + private suspend fun processManualUpdateChain( + chainId: String, + ): ResultStatus { + val snapshot = requireManualUpdateSnapshot(chainId) + val appsNeededToUpdate = snapshot.packages + .drop(snapshot.cursor) + .take(MANUAL_UPDATE_CHUNK_SIZE) + return if (appsNeededToUpdate.isEmpty()) { + completeManualUpdateChain(chainId) + ResultStatus.OK + } else { + triggerUpdateProcessOnSettings( + isConnectedToUnmeteredNetwork = isConnectedToUnMeteredNetwork(applicationContext), + appsNeededToUpdate = appsNeededToUpdate, + ) + if (skippedManualUpdateBecausePeriodicWorkIsBlocking) { + clearBlockedManualUpdateChain(chainId) + } else { + advanceManualUpdateChain(chainId, appsNeededToUpdate.size) + } + ResultStatus.OK } + } - Timber.i("Updates found: ${appsNeededToUpdate.size}; $resultStatus") - if (isAutoUpdate && shouldShowNotification) { - handleNotification(appsNeededToUpdate.size, isConnectedToUnMeteredNetwork) + private suspend fun requireManualUpdateSnapshot(chainId: String): ManualUpdateChainSnapshot { + return checkNotNull(manualUpdateChainStore.readSnapshot(chainId)) { + "missing manual update chain snapshot for chainId=$chainId" } + } - val jobsEnqueued = - triggerUpdateProcessOnSettings(isConnectedToUnMeteredNetwork, appsNeededToUpdate) - return if (jobsEnqueued.all { it.second }) ResultStatus.OK else ResultStatus.UNKNOWN + private suspend fun completeManualUpdateChain(chainId: String) { + manualUpdateChainStore.clearSnapshot(chainId) + } + + private suspend fun clearBlockedManualUpdateChain(chainId: String) { + completeManualUpdateChain(chainId) + } + + private suspend fun advanceManualUpdateChain( + chainId: String, + chunkSize: Int, + ) { + val advancedSnapshot = checkNotNull( + manualUpdateChainStore.advanceSnapshot(chainId, chunkSize) + ) { + "missing manual update chain snapshot while advancing chainId=$chainId" + } + if (advancedSnapshot.cursor < advancedSnapshot.packages.size) { + UpdatesWorkManager.appendUpdateAllWork(applicationContext, chainId) + } else { + completeManualUpdateChain(chainId) + } } @VisibleForTesting @@ -136,21 +237,41 @@ class UpdatesWorker @AssistedInject constructor( isConnectedToUnmeteredNetwork: Boolean, appsNeededToUpdate: List ): List> { - fun failedAllResponse() = appsNeededToUpdate.map { app -> Pair(app, false) } val hasStoragePermission = applicationContext.checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED val canContinue = (!isAutoUpdate || automaticInstallEnabled) && hasStoragePermission - if (!canContinue) return failedAllResponse() + return when { + !canContinue -> buildFailedAllResponse(appsNeededToUpdate) + isAutoUpdate -> { + publishPeriodicPhase(UpdatesWorkManager.PERIODIC_PHASE_APPLYING) + processUpdatesForNetwork(isConnectedToUnmeteredNetwork, appsNeededToUpdate) + } + !isManualUpdateChainContinuation && checkBlockingPeriodicUpdateRunning() -> { + skippedManualUpdateBecausePeriodicWorkIsBlocking = true + emptyList() + } + else -> processUpdatesForNetwork(isConnectedToUnmeteredNetwork, appsNeededToUpdate) + } + } + + private suspend fun processUpdatesForNetwork( + isConnectedToUnmeteredNetwork: Boolean, + appsNeededToUpdate: List, + ): List> { return if (onlyOnUnmeteredNetwork && isConnectedToUnmeteredNetwork) { startUpdateProcess(appsNeededToUpdate) } else if (!onlyOnUnmeteredNetwork) { startUpdateProcess(appsNeededToUpdate) } else { - failedAllResponse() + buildFailedAllResponse(appsNeededToUpdate) } } + private fun buildFailedAllResponse(appsNeededToUpdate: List): List> { + return appsNeededToUpdate.map { app -> Pair(app, false) } + } + @VisibleForTesting suspend fun getAvailableUpdates(): Pair, ResultStatus> { loadSettings() @@ -233,6 +354,14 @@ class UpdatesWorker @AssistedInject constructor( onlyOnUnmeteredNetwork = appPreferencesRepository.isOnlyUnmeteredNetworkEnabled() } + private suspend fun publishPeriodicPhase(phase: String) { + runCatching { + setProgress(UpdatesWorkManager.buildPeriodicPhaseData(phase)) + }.onFailure { exception -> + Timber.w(exception, "Unable to publish periodic worker progress") + } + } + /** * Checks if the device is connected to a metered connection or not * @param context current Context diff --git a/app/src/main/java/foundation/e/apps/data/updates/UpdatesManagerRepository.kt b/app/src/main/java/foundation/e/apps/data/updates/UpdatesManagerRepository.kt index ba8e51d01..dfa51bc2c 100644 --- a/app/src/main/java/foundation/e/apps/data/updates/UpdatesManagerRepository.kt +++ b/app/src/main/java/foundation/e/apps/data/updates/UpdatesManagerRepository.kt @@ -29,6 +29,7 @@ class UpdatesManagerRepository @Inject constructor( private val updatesManagerImpl: UpdatesManagerImpl, private val appPreferencesRepository: AppPreferencesRepository, ) { + fun isAppInstalledFromOtherStore(packageName: String): Boolean { return updatesManagerImpl.isAppInstalledFromOtherStore(packageName) } @@ -42,8 +43,9 @@ class UpdatesManagerRepository @Inject constructor( return Pair(UpdatesDao.appsAwaitingForUpdate, ResultStatus.OK) } return updatesManagerImpl.getUpdates().run { + val rawUpdates = first val filteredApps = - first.filter { !(!it.isFree && it.source == Source.PLAY_STORE && !it.isPurchased) } + rawUpdates.filter { !(!it.isFree && it.source == Source.PLAY_STORE && !it.isPurchased) } UpdatesDao.addItemsForUpdate(filteredApps, includesOtherStores) Pair(filteredApps, this.second) } 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 101c75b3b..8317830cd 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 @@ -41,7 +41,9 @@ 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.updates.ManualUpdateChainStore import foundation.e.apps.data.install.updates.UpdatesWorkManager +import foundation.e.apps.data.install.updates.buildManualUpdateChainSnapshot import foundation.e.apps.data.install.workmanager.InstallWorkManager import foundation.e.apps.data.installation.model.AppInstall import foundation.e.apps.data.login.core.AuthObject @@ -62,10 +64,15 @@ import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.filter import kotlinx.coroutines.launch import java.util.Locale +import java.util.UUID +import javax.inject.Inject @AndroidEntryPoint class UpdatesFragment : TimeoutFragment(R.layout.fragment_updates), ApplicationInstaller { + @Inject + lateinit var manualUpdateChainStore: ManualUpdateChainStore + private var _binding: FragmentUpdatesBinding? = null private val binding get() = _binding!! @@ -256,15 +263,14 @@ class UpdatesFragment : TimeoutFragment(R.layout.fragment_updates), ApplicationI } private fun hasBlockingUpdateWork(): Boolean { - val hasRunningPeriodicUpdateWork = periodicUpdateWorkInfos.any { - it.state == WorkInfo.State.RUNNING - } + val blockingPeriodicUpdate = + UpdatesWorkManager.getBlockingPeriodicUpdateWorkInfo(periodicUpdateWorkInfos) val hasBlockingUserUpdateWork = userUpdateWorkInfos.any { it.state == WorkInfo.State.ENQUEUED || it.state == WorkInfo.State.RUNNING || it.state == WorkInfo.State.BLOCKED } - return hasRunningPeriodicUpdateWork || hasBlockingUserUpdateWork + return blockingPeriodicUpdate != null || hasBlockingUserUpdateWork } private fun hasActiveRelevantWork(workInfos: List): Boolean { @@ -371,8 +377,26 @@ class UpdatesFragment : TimeoutFragment(R.layout.fragment_updates), ApplicationI private fun initUpdateAllButton() { binding.button.setOnClickListener { + if (hasBlockingUpdateWork()) { + return@setOnClickListener + } setButtonEnabled(false) - UpdatesWorkManager.startUpdateAllWork(requireContext()) + viewLifecycleOwner.lifecycleScope.launch { + val orderedUpdates = updatesViewModel.updatesList.value.orEmpty() + if (orderedUpdates.isEmpty()) { + updateButtonAvailability() + return@launch + } + val chainId = UUID.randomUUID().toString() + manualUpdateChainStore.writeSnapshot( + buildManualUpdateChainSnapshot( + chainId = chainId, + applications = orderedUpdates, + createdAtMillis = System.currentTimeMillis(), + ) + ) + UpdatesWorkManager.startUpdateAllWork(requireContext(), chainId) + } } } diff --git a/app/src/test/java/foundation/e/apps/data/install/updates/ManualUpdateChainStoreTest.kt b/app/src/test/java/foundation/e/apps/data/install/updates/ManualUpdateChainStoreTest.kt new file mode 100644 index 000000000..540f8a13f --- /dev/null +++ b/app/src/test/java/foundation/e/apps/data/install/updates/ManualUpdateChainStoreTest.kt @@ -0,0 +1,165 @@ +package foundation.e.apps.data.install.updates + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.google.gson.Gson +import com.google.gson.GsonBuilder +import foundation.e.apps.data.application.data.Application +import foundation.e.apps.data.enums.Source +import foundation.e.apps.data.enums.Type +import foundation.e.apps.data.installation.model.SharedLib +import foundation.e.apps.domain.model.install.Status +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +class ManualUpdateChainStoreTest { + private lateinit var context: Context + private lateinit var store: ManualUpdateChainStore + + @Before + fun setUp() { + context = ApplicationProvider.getApplicationContext() + store = ManualUpdateChainStore(context, testGson()) + } + + @After + fun tearDown() = runTest { + store.clearSnapshot(CHAIN_ID) + store.clearSnapshot(OTHER_CHAIN_ID) + } + + @Test + fun writeSnapshot_readsBackMatchingSnapshot() = runTest { + val snapshot = createSnapshot(chainId = CHAIN_ID, cursor = 1) + + store.writeSnapshot(snapshot) + + val storedSnapshot = store.readSnapshot(CHAIN_ID) + assertEquals(snapshot.chainId, storedSnapshot?.chainId) + assertEquals(snapshot.cursor, storedSnapshot?.cursor) + assertEquals(snapshot.packages.map { it.package_name }, storedSnapshot?.packages?.map { it.package_name }) + assertNull(store.readSnapshot(OTHER_CHAIN_ID)) + } + + @Test + fun writeSnapshot_preservesApplicationFieldsUsedByManualUpdateChain() = runTest { + val snapshot = createSnapshot(chainId = CHAIN_ID, cursor = 1) + + store.writeSnapshot(snapshot) + + val storedSnapshot = store.readSnapshot(CHAIN_ID) + assertNotNull(storedSnapshot) + assertEquals(snapshot.createdAtMillis, storedSnapshot?.createdAtMillis) + assertApplicationFields(snapshot.packages[0], storedSnapshot!!.packages[0]) + assertApplicationFields(snapshot.packages[1], storedSnapshot.packages[1]) + assertApplicationFields(snapshot.packages[2], storedSnapshot.packages[2]) + } + + @Test + fun advanceSnapshot_updatesCursor_andCapsAtPackageCount() = runTest { + val snapshot = createSnapshot(chainId = CHAIN_ID, cursor = 1) + store.writeSnapshot(snapshot) + + val advancedSnapshot = store.advanceSnapshot(CHAIN_ID, consumedCount = 10) + + assertNotNull(advancedSnapshot) + assertEquals(3, advancedSnapshot?.cursor) + assertEquals(3, store.readSnapshot(CHAIN_ID)?.cursor) + assertApplicationFields(snapshot.packages[0], advancedSnapshot!!.packages[0]) + } + + @Test + fun clearSnapshot_removesOnlyMatchingChain() = runTest { + store.writeSnapshot(createSnapshot(chainId = CHAIN_ID)) + + store.clearSnapshot(OTHER_CHAIN_ID) + assertNotNull(store.readSnapshot(CHAIN_ID)) + + store.clearSnapshot(CHAIN_ID) + assertNull(store.readSnapshot(CHAIN_ID)) + } + + private fun createSnapshot( + chainId: String, + cursor: Int = 0, + ): ManualUpdateChainSnapshot { + return ManualUpdateChainSnapshot( + chainId = chainId, + cursor = cursor, + createdAtMillis = 1234L, + packages = listOf( + createApplication("one"), + createApplication("two"), + createApplication("three"), + ), + ) + } + + private fun createApplication(packageSuffix: String): Application { + return Application( + _id = "app-$packageSuffix", + name = "App $packageSuffix", + package_name = "foundation.e.apps.$packageSuffix", + source = Source.PLAY_STORE, + status = Status.UPDATABLE, + type = Type.NATIVE, + icon_image_path = "icons/$packageSuffix", + latest_version_code = 42L, + offer_type = 1, + isFree = true, + originalSize = 4096L, + url = "https://example.com/$packageSuffix.apk", + isSystemApp = false, + dependentLibraries = listOf( + SharedLib( + packageName = "foundation.e.apps.lib.$packageSuffix", + versionCode = 7L, + offerType = 2, + downloadUrls = listOf("https://example.com/lib-$packageSuffix.apk"), + downloadIds = mapOf(123L to true), + ) + ), + ) + } + + private fun assertApplicationFields( + expected: Application, + actual: Application, + ) { + assertEquals(expected._id, actual._id) + assertEquals(expected.name, actual.name) + assertEquals(expected.package_name, actual.package_name) + assertEquals(expected.source, actual.source) + assertEquals(expected.status, actual.status) + assertEquals(expected.type, actual.type) + assertEquals(expected.icon_image_path, actual.icon_image_path) + assertEquals(expected.latest_version_code, actual.latest_version_code) + assertEquals(expected.offer_type, actual.offer_type) + assertEquals(expected.isFree, actual.isFree) + assertEquals(expected.originalSize, actual.originalSize) + assertEquals(expected.url, actual.url) + assertEquals(expected.isSystemApp, actual.isSystemApp) + assertEquals(expected.dependentLibraries, actual.dependentLibraries) + } + + private fun testGson(): Gson { + return GsonBuilder() + .enableComplexMapKeySerialization() + .create() + } + + companion object { + private const val CHAIN_ID = "chain-1" + private const val OTHER_CHAIN_ID = "chain-2" + } +} 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 9ab6b1ce7..83de1bfd6 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 @@ -62,13 +62,17 @@ class UpdatesWorkManagerTest { @Test fun startUpdateAllWork_buildsExpectedOneTimeRequest() { - UpdatesWorkManager.startUpdateAllWork(context) + UpdatesWorkManager.startUpdateAllWork(context, CHAIN_ID) val workInfo = getActiveUniqueWorkInfo("updates_work_user") val workSpec = getWorkSpec(workInfo.id) assertThat(workInfo.tags).contains(UpdatesWorkManager.TAG_WORK_USER_INITIATED_UPDATE) assertThat(workSpec.input.getBoolean(UpdatesWorker.IS_AUTO_UPDATE, true)).isFalse() + assertThat(workSpec.input.getString(UpdatesWorkManager.INPUT_KEY_CHAIN_ID)).isEqualTo(CHAIN_ID) + assertThat( + workSpec.input.getBoolean(UpdatesWorkManager.INPUT_KEY_IS_MANUAL_CHAIN_CONTINUATION, true) + ).isFalse() assertThat(workSpec.constraints.requiredNetworkType).isEqualTo(NetworkType.CONNECTED) assertThat(workSpec.expedited).isTrue() assertThat(workSpec.outOfQuotaPolicy) @@ -77,10 +81,10 @@ class UpdatesWorkManagerTest { @Test fun startUpdateAllWork_replacesExistingUniqueWork() { - UpdatesWorkManager.startUpdateAllWork(context) + UpdatesWorkManager.startUpdateAllWork(context, CHAIN_ID) val firstWorkId = getActiveUniqueWorkInfo("updates_work_user").id - UpdatesWorkManager.startUpdateAllWork(context) + UpdatesWorkManager.startUpdateAllWork(context, "$CHAIN_ID-next") val allWorkInfos = workManager.getWorkInfosForUniqueWork("updates_work_user").get() val activeWorkInfos = allWorkInfos.filter { !it.state.isFinished } @@ -89,6 +93,24 @@ class UpdatesWorkManagerTest { assertThat(activeWorkInfos.single().id).isNotEqualTo(firstWorkId) } + @Test + fun appendUpdateAllWork_appendsContinuationRequestWithManualTag() { + UpdatesWorkManager.startUpdateAllWork(context, CHAIN_ID) + + UpdatesWorkManager.appendUpdateAllWork(context, CHAIN_ID) + + val workInfos = workManager.getWorkInfosForUniqueWork("updates_work_user").get() + val continuationWorkInfo = workInfos.single { it.state == WorkInfo.State.BLOCKED } + val continuationWorkSpec = getWorkSpec(continuationWorkInfo.id) + + assertThat(continuationWorkInfo.tags).contains(UpdatesWorkManager.TAG_WORK_USER_INITIATED_UPDATE) + assertThat(continuationWorkSpec.input.getString(UpdatesWorkManager.INPUT_KEY_CHAIN_ID)) + .isEqualTo(CHAIN_ID) + assertThat( + continuationWorkSpec.input.getBoolean(UpdatesWorkManager.INPUT_KEY_IS_MANUAL_CHAIN_CONTINUATION, false) + ).isTrue() + } + @Test fun enqueueWork_buildsExpectedPeriodicRequest() { UpdatesWorkManager.enqueueWork(context, interval = 6, ExistingPeriodicWorkPolicy.REPLACE) @@ -132,4 +154,8 @@ class UpdatesWorkManagerTest { val workManagerImpl = WorkManagerImpl.getInstance(context) return requireNotNull(workManagerImpl.workDatabase.workSpecDao().getWorkSpec(workId.toString())) } + + companion object { + private const val CHAIN_ID = "chain-1" + } } 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 2afb16baa..5abd9f769 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 @@ -19,6 +19,7 @@ package foundation.e.apps.data.install.updates import android.app.NotificationManager +import android.Manifest import android.content.Context import android.content.SharedPreferences import android.net.ConnectivityManager @@ -35,6 +36,8 @@ import androidx.work.WorkerParameters import androidx.work.testing.WorkManagerTestInitHelper import com.aurora.gplayapi.data.models.AuthData import com.google.common.truth.Truth.assertThat +import com.google.gson.Gson +import com.google.gson.GsonBuilder import foundation.e.apps.data.ResultSupreme import foundation.e.apps.data.application.data.Application import foundation.e.apps.data.blockedApps.BlockedAppRepository @@ -43,6 +46,7 @@ import foundation.e.apps.data.enums.ResultStatus import foundation.e.apps.data.enums.Source import foundation.e.apps.data.gitlab.SystemAppsUpdatesRepository import foundation.e.apps.data.install.core.AppInstallationFacade +import foundation.e.apps.data.installation.model.SharedLib import foundation.e.apps.data.updates.UpdatesManagerRepository import foundation.e.apps.domain.model.LoginState import foundation.e.apps.domain.model.User @@ -69,6 +73,7 @@ import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf import org.robolectric.annotation.Config import kotlin.test.assertFalse @@ -444,7 +449,7 @@ class UpdatesWorkerTest { appInstallationFacade, blockedAppRepository, systemAppsUpdatesRepository, - appPreferencesRepository + appPreferencesRepository = appPreferencesRepository ) val result = worker.getAvailableUpdates() @@ -489,7 +494,7 @@ class UpdatesWorkerTest { appInstallationFacade, blockedAppRepository, systemAppsUpdatesRepository, - appPreferencesRepository + appPreferencesRepository = appPreferencesRepository ) val result = worker.getAvailableUpdates() @@ -572,7 +577,7 @@ class UpdatesWorkerTest { appInstallationFacade, blockedAppRepository, systemAppsUpdatesRepository, - appPreferencesRepository + appPreferencesRepository = appPreferencesRepository ) val result = worker.doWork() @@ -976,11 +981,254 @@ class UpdatesWorkerTest { coVerify(exactly = 1) { worker.startUpdateProcess(apps) } } + @Test + fun doWork_processesFirstManualChainChunk_andSchedulesContinuation() = runTest { + val workerContext = ApplicationProvider.getApplicationContext() + shadowOf(workerContext).grantPermissions(Manifest.permission.WRITE_EXTERNAL_STORAGE) + WorkManagerTestInitHelper.initializeTestWorkManager(workerContext) + WorkManager.getInstance(workerContext).cancelAllWork().result.get() + + val params = mock() + val inputData = Data.Builder() + .putBoolean(UpdatesWorker.IS_AUTO_UPDATE, false) + .putString(UpdatesWorkManager.INPUT_KEY_CHAIN_ID, MANUAL_CHAIN_ID) + .build() + whenever(params.inputData).thenReturn(inputData) + + val updatesManagerRepository = mock() + val sessionRepository = createDataStore() + val playStoreAuthManager = mock() + val appInstallationFacade = mock() + val blockedAppRepository = mock() + val systemAppsUpdatesRepository = mock() + val manualUpdateChainStore = createManualUpdateChainStore(workerContext) + val requestedPackages = mutableListOf() + val requestedApplications = mutableListOf() + val snapshotApplications = createManualChainApplications(20) + + manualUpdateChainStore.writeSnapshot( + buildManualUpdateChainSnapshot( + chainId = MANUAL_CHAIN_ID, + applications = snapshotApplications, + createdAtMillis = 1234L, + ) + ) + whenever(playStoreAuthManager.getValidatedAuthData()).thenReturn(ResultSupreme.Error("no auth")) + whenever(systemAppsUpdatesRepository.fetchUpdatableSystemApps(true)) + .thenReturn(ResultSupreme.Success(Unit)) + whenever(appInstallationFacade.initAppInstall(any(), any())).thenAnswer { invocation -> + val application = invocation.arguments[0] as Application + requestedApplications += application + requestedPackages += application.package_name + true + } + + val worker = createWorker( + workerContext, + params, + updatesManagerRepository, + sessionRepository, + playStoreAuthManager, + appInstallationFacade, + blockedAppRepository, + systemAppsUpdatesRepository, + manualUpdateChainStore = manualUpdateChainStore, + appPreferencesRepository = createAppPreferencesRepository(isOnlyUnmeteredNetworkEnabled = false), + ) + + val result = worker.doWork() + + assertThat(result).isEqualTo(androidx.work.ListenableWorker.Result.success()) + assertThat(requestedPackages).containsExactlyElementsIn( + snapshotApplications.take(15).map { it.package_name } + ).inOrder() + assertThat(requestedApplications.first().dependentLibraries) + .isEqualTo(snapshotApplications.first().dependentLibraries) + assertThat(requestedApplications.first().source).isEqualTo(snapshotApplications.first().source) + assertThat(requestedApplications.first().status).isEqualTo(snapshotApplications.first().status) + assertThat(requestedApplications.first().latest_version_code) + .isEqualTo(snapshotApplications.first().latest_version_code) + assertThat(manualUpdateChainStore.readSnapshot(MANUAL_CHAIN_ID)?.cursor).isEqualTo(15) + + val scheduledWorkInfos = WorkManager.getInstance(workerContext) + .getWorkInfosForUniqueWork("updates_work_user") + .get() + val continuationWork = scheduledWorkInfos.single { it.state == androidx.work.WorkInfo.State.ENQUEUED } + assertThat(continuationWork.tags).contains(UpdatesWorkManager.TAG_WORK_USER_INITIATED_UPDATE) + } + + @Test + fun doWork_processesFinalManualChainChunk_andClearsSnapshot() = runTest { + val workerContext = ApplicationProvider.getApplicationContext() + shadowOf(workerContext).grantPermissions(Manifest.permission.WRITE_EXTERNAL_STORAGE) + WorkManagerTestInitHelper.initializeTestWorkManager(workerContext) + WorkManager.getInstance(workerContext).cancelAllWork().result.get() + + val params = mock() + val inputData = Data.Builder() + .putBoolean(UpdatesWorker.IS_AUTO_UPDATE, false) + .putString(UpdatesWorkManager.INPUT_KEY_CHAIN_ID, MANUAL_CHAIN_ID) + .putBoolean(UpdatesWorkManager.INPUT_KEY_IS_MANUAL_CHAIN_CONTINUATION, true) + .build() + whenever(params.inputData).thenReturn(inputData) + + val updatesManagerRepository = mock() + val sessionRepository = createDataStore() + val playStoreAuthManager = mock() + val appInstallationFacade = mock() + val blockedAppRepository = mock() + val systemAppsUpdatesRepository = mock() + val manualUpdateChainStore = createManualUpdateChainStore(workerContext) + val requestedPackages = mutableListOf() + val snapshotApplications = createManualChainApplications(20) + + manualUpdateChainStore.writeSnapshot( + buildManualUpdateChainSnapshot( + chainId = MANUAL_CHAIN_ID, + applications = snapshotApplications, + createdAtMillis = 1234L, + ).copy(cursor = 15) + ) + whenever(playStoreAuthManager.getValidatedAuthData()).thenReturn(ResultSupreme.Error("no auth")) + whenever(systemAppsUpdatesRepository.fetchUpdatableSystemApps(true)) + .thenReturn(ResultSupreme.Success(Unit)) + whenever(appInstallationFacade.initAppInstall(any(), any())).thenAnswer { invocation -> + requestedPackages += (invocation.arguments[0] as Application).package_name + true + } + + val worker = createWorker( + workerContext, + params, + updatesManagerRepository, + sessionRepository, + playStoreAuthManager, + appInstallationFacade, + blockedAppRepository, + systemAppsUpdatesRepository, + manualUpdateChainStore = manualUpdateChainStore, + appPreferencesRepository = createAppPreferencesRepository(isOnlyUnmeteredNetworkEnabled = false), + ) + + val result = worker.doWork() + + assertThat(result).isEqualTo(androidx.work.ListenableWorker.Result.success()) + assertThat(requestedPackages).containsExactlyElementsIn( + snapshotApplications.drop(15).map { it.package_name } + ).inOrder() + assertThat(manualUpdateChainStore.readSnapshot(MANUAL_CHAIN_ID)).isNull() + } + + @Test + fun doWork_processesLargeManualChainWithoutDuplicatesOrMissingPackages() = runTest { + val workerContext = ApplicationProvider.getApplicationContext() + shadowOf(workerContext).grantPermissions(Manifest.permission.WRITE_EXTERNAL_STORAGE) + WorkManagerTestInitHelper.initializeTestWorkManager(workerContext) + WorkManager.getInstance(workerContext).cancelAllWork().result.get() + + val updatesManagerRepository = mock() + val sessionRepository = createDataStore() + val playStoreAuthManager = mock() + val appInstallationFacade = mock() + val blockedAppRepository = mock() + val systemAppsUpdatesRepository = mock() + val manualUpdateChainStore = createManualUpdateChainStore(workerContext) + val snapshotApplications = createManualChainApplications(50) + val requestedPackages = mutableListOf() + + manualUpdateChainStore.writeSnapshot( + buildManualUpdateChainSnapshot( + chainId = MANUAL_CHAIN_ID, + applications = snapshotApplications, + createdAtMillis = 1234L, + ) + ) + whenever(playStoreAuthManager.getValidatedAuthData()).thenReturn(ResultSupreme.Error("no auth")) + whenever(systemAppsUpdatesRepository.fetchUpdatableSystemApps(true)) + .thenReturn(ResultSupreme.Success(Unit)) + whenever(appInstallationFacade.initAppInstall(any(), any())).thenAnswer { invocation -> + val application = invocation.arguments[0] as Application + requestedPackages += application.package_name + application.package_name !in setOf("foundation.e.apps.10", "foundation.e.apps.35") + } + + var continuation = false + repeat(10) { + if (manualUpdateChainStore.readSnapshot(MANUAL_CHAIN_ID) == null) { + return@repeat + } + val params = mock() + whenever(params.inputData).thenReturn( + Data.Builder() + .putBoolean(UpdatesWorker.IS_AUTO_UPDATE, false) + .putString(UpdatesWorkManager.INPUT_KEY_CHAIN_ID, MANUAL_CHAIN_ID) + .putBoolean(UpdatesWorkManager.INPUT_KEY_IS_MANUAL_CHAIN_CONTINUATION, continuation) + .build() + ) + val worker = createWorker( + workerContext, + params, + updatesManagerRepository, + sessionRepository, + playStoreAuthManager, + appInstallationFacade, + blockedAppRepository, + systemAppsUpdatesRepository, + manualUpdateChainStore = manualUpdateChainStore, + appPreferencesRepository = createAppPreferencesRepository(isOnlyUnmeteredNetworkEnabled = false), + ) + + val result = worker.doWork() + + assertThat(result).isEqualTo(androidx.work.ListenableWorker.Result.success()) + continuation = true + } + + assertThat(manualUpdateChainStore.readSnapshot(MANUAL_CHAIN_ID)).isNull() + assertThat(requestedPackages).containsExactlyElementsIn( + snapshotApplications.map { it.package_name } + ).inOrder() + } + private fun createDataStore(): InMemorySessionRepository { return InMemorySessionRepository() } + private fun createManualUpdateChainStore(context: Context): ManualUpdateChainStore { + return ManualUpdateChainStore(context, testGson()) + } + + private fun createManualChainApplications(count: Int): List { + return (1..count).map { index -> + Application( + _id = index.toString(), + name = "App $index", + package_name = "foundation.e.apps.$index", + status = Status.UPDATABLE, + source = Source.OPEN_SOURCE, + filterLevel = FilterLevel.NONE, + isFree = true, + latest_version_code = index.toLong(), + dependentLibraries = listOf( + SharedLib( + packageName = "foundation.e.apps.lib.$index", + versionCode = index.toLong(), + offerType = index, + downloadUrls = listOf("https://example.com/lib-$index.apk"), + downloadIds = mapOf(index.toLong() to true), + ) + ), + ) + } + } + + private fun testGson(): Gson { + return GsonBuilder() + .enableComplexMapKeySerialization() + .create() + } + private fun createWorker( context: Context, params: WorkerParameters, @@ -990,11 +1238,13 @@ class UpdatesWorkerTest { appInstallationFacade: AppInstallationFacade, blockedAppRepository: BlockedAppRepository, systemAppsUpdatesRepository: SystemAppsUpdatesRepository, + manualUpdateChainStore: ManualUpdateChainStore = mock(), appPreferencesRepository: AppPreferencesRepository = createAppPreferencesRepository(), ): UpdatesWorker { return UpdatesWorker( context, params, + manualUpdateChainStore, updatesManagerRepository, blockedAppRepository, systemAppsUpdatesRepository, @@ -1057,6 +1307,10 @@ class UpdatesWorkerTest { } } + companion object { + private const val MANUAL_CHAIN_ID = "manual-chain-id" + } + private class InMemorySessionRepository : SessionRepository { private val userState = MutableStateFlow(User.NO_GOOGLE) private val loginStateState = MutableStateFlow(LoginState.UNAVAILABLE) diff --git a/app/src/test/java/foundation/e/apps/ui/updates/UpdatesFragmentTest.kt b/app/src/test/java/foundation/e/apps/ui/updates/UpdatesFragmentTest.kt new file mode 100644 index 000000000..31c93aea1 --- /dev/null +++ b/app/src/test/java/foundation/e/apps/ui/updates/UpdatesFragmentTest.kt @@ -0,0 +1,93 @@ +package foundation.e.apps.ui.updates + +import android.content.Context +import android.view.LayoutInflater +import android.view.ContextThemeWrapper +import androidx.test.core.app.ApplicationProvider +import androidx.work.WorkInfo +import foundation.e.apps.R +import foundation.e.apps.data.application.data.Application +import foundation.e.apps.databinding.FragmentUpdatesBinding +import foundation.e.apps.domain.model.install.Status +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class UpdatesFragmentTest { + @Test + fun updateButtonAvailability_disablesButton_whenManualChainWorkIsActive() { + val fragment = createFragment() + + setPrivateField(fragment, "displayedUpdates", listOf(Application(status = Status.UPDATABLE))) + setPrivateField(fragment, "userUpdateWorkInfos", listOf(createWorkInfo(WorkInfo.State.RUNNING))) + + invokePrivateMethod(fragment, "updateButtonAvailability") + + assertFalse(readBinding(fragment).button.isEnabled) + } + + @Test + fun updateAllClick_doesNothing_whenManualChainWorkIsAlreadyActive() { + val fragment = createFragment() + val binding = readBinding(fragment) + + setPrivateField(fragment, "displayedUpdates", listOf(Application(status = Status.UPDATABLE))) + setPrivateField(fragment, "userUpdateWorkInfos", listOf(createWorkInfo(WorkInfo.State.ENQUEUED))) + binding.button.isEnabled = true + + invokePrivateMethod(fragment, "initUpdateAllButton") + binding.button.performClick() + + assertTrue(binding.button.isEnabled) + } + + private fun createFragment(): UpdatesFragment { + val fragment = UpdatesFragment() + val context = ApplicationProvider.getApplicationContext() + val themedContext = ContextThemeWrapper(context, R.style.Theme_Apps) + val binding = FragmentUpdatesBinding.inflate(LayoutInflater.from(themedContext)) + setPrivateField(fragment, "_binding", binding) + setPrivateField(fragment, "displayedUpdates", emptyList()) + setPrivateField(fragment, "periodicUpdateWorkInfos", emptyList()) + setPrivateField(fragment, "userUpdateWorkInfos", emptyList()) + setPrivateField(fragment, "taggedInstallWorkInfos", emptyList()) + setPrivateField(fragment, "legacyInstallWorkInfos", emptyList()) + return fragment + } + + private fun createWorkInfo(state: WorkInfo.State): WorkInfo { + return mock().also { workInfo -> + whenever(workInfo.state).thenReturn(state) + } + } + + private fun readBinding(fragment: UpdatesFragment): FragmentUpdatesBinding { + return readPrivateField(fragment, "_binding") + } + + private fun invokePrivateMethod(target: Any, methodName: String) { + target.javaClass.getDeclaredMethod(methodName).apply { + isAccessible = true + invoke(target) + } + } + + private fun setPrivateField(target: Any, fieldName: String, value: Any?) { + target.javaClass.getDeclaredField(fieldName).apply { + isAccessible = true + set(target, value) + } + } + + @Suppress("UNCHECKED_CAST") + private fun readPrivateField(target: Any, fieldName: String): T { + return target.javaClass.getDeclaredField(fieldName).apply { + isAccessible = true + }.get(target) as T + } +} -- GitLab From 4ecb23cd51b4b61573e332781b5e990e9083e730 Mon Sep 17 00:00:00 2001 From: Fahim Masud Choudhury Date: Thu, 7 May 2026 19:21:29 +0600 Subject: [PATCH 2/3] fix(updates): show empty state after refresh Use displayedUpdates instead of the adapter item count when deciding whether to show the 'All apps are up-to-date' state. submitList updates the adapter asynchronously, so itemCount could stay stale until the fragment was reopened. --- .../main/java/foundation/e/apps/ui/updates/UpdatesFragment.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 8317830cd..c61c5bc8f 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 @@ -409,7 +409,7 @@ class UpdatesFragment : TimeoutFragment(R.layout.fragment_updates), ApplicationI override fun stopLoadingUI() { binding.progressBar.visibility = View.GONE - if ((binding.recyclerView.adapter?.itemCount ?: 0) > 0) { + if (displayedUpdates.isNotEmpty()) { binding.noUpdates.visibility = View.GONE binding.recyclerView.visibility = View.VISIBLE return -- GitLab From d9affcd5707c5d16ee11904e3b21d589880e26aa Mon Sep 17 00:00:00 2001 From: Fahim Masud Choudhury Date: Fri, 8 May 2026 17:55:30 +0600 Subject: [PATCH 3/3] refactor(updates): tolerate partial enqueue failures Continue manual update chains when only some apps fail to enqueue so remaining updates can proceed; report failure when an entire chunk cannot be scheduled. --- .../data/install/updates/UpdatesWorker.kt | 14 +- .../data/install/updates/UpdatesWorkerTest.kt | 134 ++++++++++++++++++ 2 files changed, 146 insertions(+), 2 deletions(-) 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 88b181342..21a8cf937 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 @@ -189,16 +189,26 @@ class UpdatesWorker @AssistedInject constructor( completeManualUpdateChain(chainId) ResultStatus.OK } else { - triggerUpdateProcessOnSettings( + val enqueueResults = triggerUpdateProcessOnSettings( isConnectedToUnmeteredNetwork = isConnectedToUnMeteredNetwork(applicationContext), appsNeededToUpdate = appsNeededToUpdate, ) if (skippedManualUpdateBecausePeriodicWorkIsBlocking) { clearBlockedManualUpdateChain(chainId) + ResultStatus.OK + } else if (enqueueResults.isEmpty() || enqueueResults.none { it.second }) { + EventBus.invokeEvent(AppEvent.UpdateEvent(ResultSupreme.WorkError(ResultStatus.UNKNOWN))) + ResultStatus.UNKNOWN } else { + // Manual Update All is best-effort per app: failed enqueue attempts remain + // visible in the updates UI for manual retry while the chain continues. + if (enqueueResults.any { !it.second }) { + Timber.w("Some apps failed to enqueue during manual update chain") + EventBus.invokeEvent(AppEvent.UpdateEvent(ResultSupreme.WorkError(ResultStatus.UNKNOWN))) + } advanceManualUpdateChain(chainId, appsNeededToUpdate.size) + ResultStatus.OK } - ResultStatus.OK } } 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 5abd9f769..829784831 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 @@ -1057,6 +1057,140 @@ class UpdatesWorkerTest { assertThat(continuationWork.tags).contains(UpdatesWorkManager.TAG_WORK_USER_INITIATED_UPDATE) } + @Test + fun doWork_continuesManualChain_whenChunkPartiallyFailsToEnqueue() = runTest { + val workerContext = ApplicationProvider.getApplicationContext() + shadowOf(workerContext).grantPermissions(Manifest.permission.WRITE_EXTERNAL_STORAGE) + WorkManagerTestInitHelper.initializeTestWorkManager(workerContext) + WorkManager.getInstance(workerContext).cancelAllWork().result.get() + + val params = mock() + val inputData = Data.Builder() + .putBoolean(UpdatesWorker.IS_AUTO_UPDATE, false) + .putString(UpdatesWorkManager.INPUT_KEY_CHAIN_ID, MANUAL_CHAIN_ID) + .build() + whenever(params.inputData).thenReturn(inputData) + + val updatesManagerRepository = mock() + val sessionRepository = createDataStore() + val playStoreAuthManager = mock() + val appInstallationFacade = mock() + val blockedAppRepository = mock() + val systemAppsUpdatesRepository = mock() + val manualUpdateChainStore = createManualUpdateChainStore(workerContext) + val requestedPackages = mutableListOf() + val snapshotApplications = createManualChainApplications(20) + + manualUpdateChainStore.writeSnapshot( + buildManualUpdateChainSnapshot( + chainId = MANUAL_CHAIN_ID, + applications = snapshotApplications, + createdAtMillis = 1234L, + ) + ) + whenever(playStoreAuthManager.getValidatedAuthData()).thenReturn(ResultSupreme.Error("no auth")) + whenever(systemAppsUpdatesRepository.fetchUpdatableSystemApps(true)) + .thenReturn(ResultSupreme.Success(Unit)) + whenever(appInstallationFacade.initAppInstall(any(), any())).thenAnswer { invocation -> + val packageName = (invocation.arguments[0] as Application).package_name + requestedPackages += packageName + packageName != "foundation.e.apps.10" + } + + val worker = createWorker( + workerContext, + params, + updatesManagerRepository, + sessionRepository, + playStoreAuthManager, + appInstallationFacade, + blockedAppRepository, + systemAppsUpdatesRepository, + manualUpdateChainStore = manualUpdateChainStore, + appPreferencesRepository = createAppPreferencesRepository(isOnlyUnmeteredNetworkEnabled = false), + ) + + val result = worker.doWork() + + assertThat(result).isEqualTo(androidx.work.ListenableWorker.Result.success()) + assertThat(requestedPackages).containsExactlyElementsIn( + snapshotApplications.take(15).map { it.package_name } + ).inOrder() + assertThat(manualUpdateChainStore.readSnapshot(MANUAL_CHAIN_ID)?.cursor).isEqualTo(15) + + val scheduledWorkInfos = WorkManager.getInstance(workerContext) + .getWorkInfosForUniqueWork("updates_work_user") + .get() + val continuationWork = scheduledWorkInfos.single { it.state == androidx.work.WorkInfo.State.ENQUEUED } + assertThat(continuationWork.tags).contains(UpdatesWorkManager.TAG_WORK_USER_INITIATED_UPDATE) + } + + @Test + fun doWork_failsManualChain_whenWholeChunkFailsToEnqueue() = runTest { + val workerContext = ApplicationProvider.getApplicationContext() + shadowOf(workerContext).grantPermissions(Manifest.permission.WRITE_EXTERNAL_STORAGE) + WorkManagerTestInitHelper.initializeTestWorkManager(workerContext) + WorkManager.getInstance(workerContext).cancelAllWork().result.get() + + val params = mock() + val inputData = Data.Builder() + .putBoolean(UpdatesWorker.IS_AUTO_UPDATE, false) + .putString(UpdatesWorkManager.INPUT_KEY_CHAIN_ID, MANUAL_CHAIN_ID) + .build() + whenever(params.inputData).thenReturn(inputData) + + val updatesManagerRepository = mock() + val sessionRepository = createDataStore() + val playStoreAuthManager = mock() + val appInstallationFacade = mock() + val blockedAppRepository = mock() + val systemAppsUpdatesRepository = mock() + val manualUpdateChainStore = createManualUpdateChainStore(workerContext) + val requestedPackages = mutableListOf() + val snapshotApplications = createManualChainApplications(20) + + manualUpdateChainStore.writeSnapshot( + buildManualUpdateChainSnapshot( + chainId = MANUAL_CHAIN_ID, + applications = snapshotApplications, + createdAtMillis = 1234L, + ) + ) + whenever(playStoreAuthManager.getValidatedAuthData()).thenReturn(ResultSupreme.Error("no auth")) + whenever(systemAppsUpdatesRepository.fetchUpdatableSystemApps(true)) + .thenReturn(ResultSupreme.Success(Unit)) + whenever(appInstallationFacade.initAppInstall(any(), any())).thenAnswer { invocation -> + requestedPackages += (invocation.arguments[0] as Application).package_name + false + } + + val worker = createWorker( + workerContext, + params, + updatesManagerRepository, + sessionRepository, + playStoreAuthManager, + appInstallationFacade, + blockedAppRepository, + systemAppsUpdatesRepository, + manualUpdateChainStore = manualUpdateChainStore, + appPreferencesRepository = createAppPreferencesRepository(isOnlyUnmeteredNetworkEnabled = false), + ) + + val result = worker.doWork() + + assertThat(result).isEqualTo(androidx.work.ListenableWorker.Result.failure()) + assertThat(requestedPackages).containsExactlyElementsIn( + snapshotApplications.take(15).map { it.package_name } + ).inOrder() + assertThat(manualUpdateChainStore.readSnapshot(MANUAL_CHAIN_ID)?.cursor).isEqualTo(0) + + val scheduledWorkInfos = WorkManager.getInstance(workerContext) + .getWorkInfosForUniqueWork("updates_work_user") + .get() + assertThat(scheduledWorkInfos.filter { !it.state.isFinished }).isEmpty() + } + @Test fun doWork_processesFinalManualChainChunk_andClearsSnapshot() = runTest { val workerContext = ApplicationProvider.getApplicationContext() -- GitLab