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 34aed8ea7c189c330417cb43cbb08365d20ba96d..e2b9d9fde52aa6c5b16c8b5f99851af772d01bc8 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 00d7665ed0deb420dfc95e1c3262e79cc7084cb3..e27e1543ee8ee62127c0857fdd5b00ee7c91fe28 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 9c55e01c87ac4c074584854fcae948ac9830aadb..63f82bddf6512f4aad1a3f368a08fd767d7fc422 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 0000000000000000000000000000000000000000..252650782eca2dc8daaee07c2228c4980a4e4f9e --- /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 0000000000000000000000000000000000000000..11a95f69955bfb99ea3a569844eaa068a34e48a7 --- /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 3b44ca8ff9d11d0e7b5c1c225e2862ee43ab1ec7..5738fc4d62aabe72ef726706c7bc4e279af1661d 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 af017400caa868c79ed490357d205cab615ad2f7..21a8cf937514d0d78ec74fa068bb993ff7b1c970 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,94 @@ 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) + } - if (resultStatus != ResultStatus.OK) { - return resultStatus + val jobsEnqueued = + triggerUpdateProcessOnSettings(isConnectedToUnMeteredNetwork, appsNeededToUpdate) + if (skippedManualUpdateBecausePeriodicWorkIsBlocking) { + ResultStatus.OK + } else if (jobsEnqueued.all { it.second }) { + ResultStatus.OK + } else { + ResultStatus.UNKNOWN + } + } } + } - Timber.i("Updates found: ${appsNeededToUpdate.size}; $resultStatus") - if (isAutoUpdate && shouldShowNotification) { - handleNotification(appsNeededToUpdate.size, isConnectedToUnMeteredNetwork) + 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 { + 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 + } } + } + + 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 +247,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 +364,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 ba8e51d01c5910afdadaecfe6448975a9e8892f7..dfa51bc2cfb83d81e07f9284d4025d3d87f9c21b 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 101c75b3b1cba1ddf7a31a196a2ba14f89ff8892..c61c5bc8fc7965c53cc89a474a52fc656a7197ea 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) + } } } @@ -385,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 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 0000000000000000000000000000000000000000..540f8a13f0697e471ee0d548a6cf9e1623663ae5 --- /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 9ab6b1ce75a345d18fe5ad3f624e12fddcf6df49..83de1bfd66391df90643a96edd4f8d2515d3d5d3 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 2afb16baa7dc868a1c420a9a4a72fd4e25f6fa1a..82978483171276b13bc706d2b6f30805da658171 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,388 @@ 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_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() + 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 +1372,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 +1441,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 0000000000000000000000000000000000000000..31c93aea1627774270a15bee4b1a61dbf397dab7 --- /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 + } +}