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
+ }
+}