From e2a01deb83fb53dcc8a79b826ce35ce90c47a8b1 Mon Sep 17 00:00:00 2001 From: dev-12 Date: Tue, 13 Jan 2026 11:34:08 +0530 Subject: [PATCH 01/13] refactor: fail worker when install processor fails always returning success masked install failures and mislead downstream retry/UX logic --- .../foundation/e/apps/install/workmanager/InstallAppWorker.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/foundation/e/apps/install/workmanager/InstallAppWorker.kt b/app/src/main/java/foundation/e/apps/install/workmanager/InstallAppWorker.kt index 42de78741..0344e45ef 100644 --- a/app/src/main/java/foundation/e/apps/install/workmanager/InstallAppWorker.kt +++ b/app/src/main/java/foundation/e/apps/install/workmanager/InstallAppWorker.kt @@ -60,14 +60,14 @@ class InstallAppWorker @AssistedInject constructor( override suspend fun doWork(): Result { val fusedDownloadId = params.inputData.getString(INPUT_DATA_FUSED_DOWNLOAD) ?: "" val isPackageUpdate = params.inputData.getBoolean(IS_UPDATE_WORK, false) - appInstallProcessor.processInstall(fusedDownloadId, isPackageUpdate) { title -> + val response = appInstallProcessor.processInstall(fusedDownloadId, isPackageUpdate) { title -> setForeground( createForegroundInfo( "${context.getString(R.string.installing)} $title" ) ) } - return Result.success() + return if (response.isSuccess) Result.success() else Result.failure() } private fun createForegroundInfo(progress: String): ForegroundInfo { -- GitLab From 4454432859a95296591a03ddf692a4b8fab991b2 Mon Sep 17 00:00:00 2001 From: dev-12 Date: Tue, 20 Jan 2026 10:49:12 +0530 Subject: [PATCH 02/13] refactor: require non-null foreground callback The processor expects a foreground update path in normal installs, the nullable callback existed only to simplify tests. --- .../e/apps/install/workmanager/AppInstallProcessor.kt | 4 ++-- .../e/apps/installProcessor/AppInstallProcessorTest.kt | 4 +++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/foundation/e/apps/install/workmanager/AppInstallProcessor.kt b/app/src/main/java/foundation/e/apps/install/workmanager/AppInstallProcessor.kt index d7e933e05..a5f63a40d 100644 --- a/app/src/main/java/foundation/e/apps/install/workmanager/AppInstallProcessor.kt +++ b/app/src/main/java/foundation/e/apps/install/workmanager/AppInstallProcessor.kt @@ -270,7 +270,7 @@ class AppInstallProcessor @Inject constructor( suspend fun processInstall( fusedDownloadId: String, isItUpdateWork: Boolean, - runInForeground: (suspend (String) -> Unit)? = null + runInForeground: (suspend (String) -> Unit) ): Result { var appInstall: AppInstall? = null try { @@ -306,7 +306,7 @@ class AppInstallProcessor @Inject constructor( ) } - runInForeground?.invoke(it.name) + runInForeground.invoke(it.name) startAppInstallationProcess(it) } diff --git a/app/src/test/java/foundation/e/apps/installProcessor/AppInstallProcessorTest.kt b/app/src/test/java/foundation/e/apps/installProcessor/AppInstallProcessorTest.kt index 98c23a525..0ff701f54 100644 --- a/app/src/test/java/foundation/e/apps/installProcessor/AppInstallProcessorTest.kt +++ b/app/src/test/java/foundation/e/apps/installProcessor/AppInstallProcessorTest.kt @@ -208,7 +208,9 @@ class AppInstallProcessorTest { } private suspend fun runProcessInstall(appInstall: AppInstall): AppInstall? { - appInstallProcessor.processInstall(appInstall.id, false) + appInstallProcessor.processInstall(appInstall.id, false) { + // _ignored_ + } return fakeFusedDownloadDAO.getDownloadById(appInstall.id) } -- GitLab From 4bd00524e22172aaf5baea0b6ef4a4c92ae20995 Mon Sep 17 00:00:00 2001 From: dev-12 Date: Wed, 4 Feb 2026 22:40:29 +0530 Subject: [PATCH 03/13] refactor: return enqueue result from initAppInstall MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Return the enqueue outcome in preparation for the follow‑up change that will fail installs when enqueueing doesn’t succeed --- .../e/apps/install/workmanager/AppInstallProcessor.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/foundation/e/apps/install/workmanager/AppInstallProcessor.kt b/app/src/main/java/foundation/e/apps/install/workmanager/AppInstallProcessor.kt index a5f63a40d..e5b0a9920 100644 --- a/app/src/main/java/foundation/e/apps/install/workmanager/AppInstallProcessor.kt +++ b/app/src/main/java/foundation/e/apps/install/workmanager/AppInstallProcessor.kt @@ -85,7 +85,7 @@ class AppInstallProcessor @Inject constructor( suspend fun initAppInstall( application: Application, isAnUpdate: Boolean = false - ) { + ): Boolean { val appInstall = AppInstall( application._id, application.source, @@ -113,7 +113,7 @@ class AppInstallProcessor @Inject constructor( application.status == Status.UPDATABLE || appInstallComponents.appManagerWrapper.isFusedDownloadInstalled(appInstall) - enqueueFusedDownload(appInstall, isUpdate, application.isSystemApp) + return enqueueFusedDownload(appInstall, isUpdate, application.isSystemApp) } /** -- GitLab From 78ba3e61be6c56584cde3ebedf8b7d593ffca200 Mon Sep 17 00:00:00 2001 From: dev-12 Date: Wed, 4 Feb 2026 22:55:45 +0530 Subject: [PATCH 04/13] refactor: return update enqueue results and simplify flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Return a per‑app enqueue outcome so the next commit in this series can fail or retry based on those results, and so a worker cancellation request is not ignored but recorded as a failed outcome. The early‑continue structure also makes the skip and cancellation branches clearer and keeps the main path focused on the actual enqueue. --- .../e/apps/install/updates/UpdatesWorker.kt | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/foundation/e/apps/install/updates/UpdatesWorker.kt b/app/src/main/java/foundation/e/apps/install/updates/UpdatesWorker.kt index 032344b87..0a2619719 100644 --- a/app/src/main/java/foundation/e/apps/install/updates/UpdatesWorker.kt +++ b/app/src/main/java/foundation/e/apps/install/updates/UpdatesWorker.kt @@ -229,14 +229,22 @@ class UpdatesWorker @AssistedInject constructor( private suspend fun startUpdateProcess( appsNeededToUpdate: List, authData: AuthData - ) { - appsNeededToUpdate.forEach { fusedApp -> - if (!fusedApp.isFree && authData.isAnonymous) { - return@forEach + ): List> { + val response = mutableListOf>() + for (fusedApp in appsNeededToUpdate) { + val shouldSkip = (!fusedApp.isFree && authData.isAnonymous) + if (shouldSkip.or(isStopped)) { // respect the stop signal + response.add(Pair(fusedApp, false)) + continue } - - appInstallProcessor.initAppInstall(fusedApp, true) + // TODO this fires up another worker so cancellation of that is not guaranteed in case of parent (this) + // worker stops (which shouldn't be the case).. worker are supposed to be cancelled in case when constraint + // are no longer met maybe we should pass the constraints as well? + // and of course the install worker need to respect the cancellations as well + val status = appInstallProcessor.initAppInstall(fusedApp, true) + response.add(Pair(fusedApp, status)) } + return response } private fun loadSettings() { -- GitLab From 2bafe9dcc5952a67d55edde5b866f6ef85cf9f46 Mon Sep 17 00:00:00 2001 From: dev-12 Date: Wed, 4 Feb 2026 23:48:46 +0530 Subject: [PATCH 05/13] refactor: move auth lookup into startUpdateProcess MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Centralize auth lookup within startUpdateProcess so callers don’t need to pass auth around. - Keeps the update flow consistent and avoids threading auth state through multiple call sites. --- .../e/apps/install/updates/UpdatesWorker.kt | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/foundation/e/apps/install/updates/UpdatesWorker.kt b/app/src/main/java/foundation/e/apps/install/updates/UpdatesWorker.kt index 0a2619719..ab85812be 100644 --- a/app/src/main/java/foundation/e/apps/install/updates/UpdatesWorker.kt +++ b/app/src/main/java/foundation/e/apps/install/updates/UpdatesWorker.kt @@ -204,9 +204,9 @@ class UpdatesWorker @AssistedInject constructor( PackageManager.PERMISSION_GRANTED if ((!isAutoUpdate || automaticInstallEnabled) && hasStoragePermission) { if (onlyOnUnmeteredNetwork && isConnectedToUnmeteredNetwork) { - startUpdateProcess(appsNeededToUpdate, authData) + startUpdateProcess(appsNeededToUpdate) } else if (!onlyOnUnmeteredNetwork) { - startUpdateProcess(appsNeededToUpdate, authData) + startUpdateProcess(appsNeededToUpdate) } } } @@ -226,14 +226,13 @@ class UpdatesWorker @AssistedInject constructor( } } - private suspend fun startUpdateProcess( - appsNeededToUpdate: List, - authData: AuthData - ): List> { + private suspend fun startUpdateProcess(appsNeededToUpdate: List): List> { val response = mutableListOf>() + val authData = authenticatorRepository.getValidatedAuthData() + val isNotLoggedIntoPersonalAccount = !authData.isValidData() || authData.data?.isAnonymous == true for (fusedApp in appsNeededToUpdate) { - val shouldSkip = (!fusedApp.isFree && authData.isAnonymous) - if (shouldSkip.or(isStopped)) { // respect the stop signal + val shouldSkip = (!fusedApp.isFree && isNotLoggedIntoPersonalAccount) + if (shouldSkip.or(isStopped)) { // respect the stop signal as well response.add(Pair(fusedApp, false)) continue } -- GitLab From e889b6784bd21cc89771b87c7e647ed352d945f3 Mon Sep 17 00:00:00 2001 From: dev-12 Date: Thu, 5 Feb 2026 00:02:04 +0530 Subject: [PATCH 06/13] refactor: return fetch status and simplify control flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Return a status so callers can react to failures instead of failing silently, and simplify control flow by making non‑success/empty responses explicit failures. --- .../gitlab/SystemAppsUpdatesRepository.kt | 21 ++++++++++++------- .../apps/install/updates/UpdatesWorkerTest.kt | 3 ++- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/foundation/e/apps/data/gitlab/SystemAppsUpdatesRepository.kt b/app/src/main/java/foundation/e/apps/data/gitlab/SystemAppsUpdatesRepository.kt index ac068159b..9071df854 100644 --- a/app/src/main/java/foundation/e/apps/data/gitlab/SystemAppsUpdatesRepository.kt +++ b/app/src/main/java/foundation/e/apps/data/gitlab/SystemAppsUpdatesRepository.kt @@ -20,6 +20,7 @@ package foundation.e.apps.data.gitlab import android.content.Context import android.os.Build import dagger.hilt.android.qualifiers.ApplicationContext +import foundation.e.apps.data.ResultSupreme import foundation.e.apps.data.application.ApplicationDataManager import foundation.e.apps.data.application.data.Application import foundation.e.apps.data.enums.Source @@ -63,7 +64,7 @@ class SystemAppsUpdatesRepository @Inject constructor( return systemAppProjectList.map { it.packageName } } - suspend fun fetchUpdatableSystemApps(forceRefresh: Boolean = false) { + suspend fun fetchUpdatableSystemApps(forceRefresh: Boolean = false): ResultSupreme { val result = handleNetworkResult { if (getUpdatableSystemApps().isNotEmpty() && !forceRefresh) { return@handleNetworkResult @@ -71,18 +72,22 @@ class SystemAppsUpdatesRepository @Inject constructor( val endPoint = getUpdatableSystemAppEndPoint() val response = updatableSystemAppsApi.getUpdatableSystemApps(endPoint) - - if (response.isSuccessful && !response.body().isNullOrEmpty()) { - systemAppProjectList.clear() - response.body()?.let { systemAppProjectList.addAll(it) } - } else { - Timber.e("Failed to fetch updatable apps: ${response.errorBody()?.string()}") + check(response.isSuccessful) { + "request failed! code : (${response.code()})" + } + val body = response.body() + if (body.isNullOrEmpty()) { + error("response is empty! despite response indicating success") } + + systemAppProjectList.clear() + systemAppProjectList.addAll(body) } if (!result.isSuccess()) { - Timber.e("Network error when fetching updatable apps - ${result.message}") + Timber.e("error when fetching updatable apps - ${result.message}") } + return result } private fun getUpdatableSystemAppEndPoint(): EndPoint { diff --git a/app/src/test/java/foundation/e/apps/install/updates/UpdatesWorkerTest.kt b/app/src/test/java/foundation/e/apps/install/updates/UpdatesWorkerTest.kt index f069f13d1..263d08c99 100644 --- a/app/src/test/java/foundation/e/apps/install/updates/UpdatesWorkerTest.kt +++ b/app/src/test/java/foundation/e/apps/install/updates/UpdatesWorkerTest.kt @@ -124,7 +124,8 @@ class UpdatesWorkerTest { ) whenever(updatesManagerRepository.getUpdatesOSS()).thenReturn(Pair(applications, ResultStatus.OK)) - whenever(systemAppsUpdatesRepository.fetchUpdatableSystemApps(true)).thenReturn(Unit) + whenever(systemAppsUpdatesRepository.fetchUpdatableSystemApps(true)) + .thenReturn(ResultSupreme.Success(Unit)) val worker = UpdatesWorker( workerContext, -- GitLab From cfbd41d84495991b0e6fe7d2edaea3bcfc8a08db Mon Sep 17 00:00:00 2001 From: dev-12 Date: Thu, 5 Feb 2026 01:08:59 +0530 Subject: [PATCH 07/13] refactor: return enqueue status and simplify control flow Return a boolean outcome so callers can decide how to handle enqueue failures in later commits, and simplify control flow by using early returns for validation and precondition failures. --- .../e/apps/install/updates/UpdatesWorker.kt | 5 +- .../workmanager/AppInstallProcessor.kt | 61 +++++++++++-------- 2 files changed, 37 insertions(+), 29 deletions(-) diff --git a/app/src/main/java/foundation/e/apps/install/updates/UpdatesWorker.kt b/app/src/main/java/foundation/e/apps/install/updates/UpdatesWorker.kt index ab85812be..bf58de278 100644 --- a/app/src/main/java/foundation/e/apps/install/updates/UpdatesWorker.kt +++ b/app/src/main/java/foundation/e/apps/install/updates/UpdatesWorker.kt @@ -226,6 +226,7 @@ class UpdatesWorker @AssistedInject constructor( } } + // returns list of Pair(app, status(success|failed)) private suspend fun startUpdateProcess(appsNeededToUpdate: List): List> { val response = mutableListOf>() val authData = authenticatorRepository.getValidatedAuthData() @@ -236,10 +237,6 @@ class UpdatesWorker @AssistedInject constructor( response.add(Pair(fusedApp, false)) continue } - // TODO this fires up another worker so cancellation of that is not guaranteed in case of parent (this) - // worker stops (which shouldn't be the case).. worker are supposed to be cancelled in case when constraint - // are no longer met maybe we should pass the constraints as well? - // and of course the install worker need to respect the cancellations as well val status = appInstallProcessor.initAppInstall(fusedApp, true) response.add(Pair(fusedApp, status)) } diff --git a/app/src/main/java/foundation/e/apps/install/workmanager/AppInstallProcessor.kt b/app/src/main/java/foundation/e/apps/install/workmanager/AppInstallProcessor.kt index e5b0a9920..92a77826a 100644 --- a/app/src/main/java/foundation/e/apps/install/workmanager/AppInstallProcessor.kt +++ b/app/src/main/java/foundation/e/apps/install/workmanager/AppInstallProcessor.kt @@ -127,8 +127,8 @@ class AppInstallProcessor @Inject constructor( appInstall: AppInstall, isAnUpdate: Boolean = false, isSystemApp: Boolean = false - ) { - try { + ): Boolean { + return try { val user = appLoungeDataStore.getUser() if (!isSystemApp && (user == User.GOOGLE || user == User.ANONYMOUS)) { val authData = appLoungeDataStore.getAuthData() @@ -137,39 +137,50 @@ class AppInstallProcessor @Inject constructor( } } - if (appInstall.type != Type.PWA && !updateDownloadUrls(appInstall)) return - - val downloadAdded = appInstallComponents.appManagerWrapper.addDownload(appInstall) - if (!downloadAdded) { - Timber.i("Update adding ABORTED! status: $downloadAdded") - return - } - - if (!validateAgeLimit(appInstall)) return - - if (!context.isNetworkAvailable()) { - appInstallComponents.appManagerWrapper.installationIssue(appInstall) - EventBus.invokeEvent(AppEvent.NoInternetEvent(false)) - return - } - - if (StorageComputer.spaceMissing(appInstall) > 0) { - Timber.d("Storage is not available for: ${appInstall.name} size: ${appInstall.appSize}") - storageNotificationManager.showNotEnoughSpaceNotification(appInstall) - appInstallComponents.appManagerWrapper.installationIssue(appInstall) - EventBus.invokeEvent(AppEvent.ErrorMessageEvent(R.string.not_enough_storage)) - return - } + if (!canEnqueue(appInstall)) return false appInstallComponents.appManagerWrapper.updateAwaiting(appInstall) InstallWorkManager.enqueueWork(appInstall, isAnUpdate) + true } catch (e: Exception) { Timber.e( e, "Enqueuing App install work is failed for ${appInstall.packageName} exception: ${e.localizedMessage}" ) appInstallComponents.appManagerWrapper.installationIssue(appInstall) + false + } + } + + private suspend fun canEnqueue(appInstall: AppInstall): Boolean { + if (appInstall.type != Type.PWA && !updateDownloadUrls(appInstall)) { + return false + } + + if (!appInstallComponents.appManagerWrapper.addDownload(appInstall)) { + Timber.i("Update adding ABORTED! status") + return false + } + + if (!validateAgeLimit(appInstall)) { + return false + } + + if (!context.isNetworkAvailable()) { + appInstallComponents.appManagerWrapper.installationIssue(appInstall) + EventBus.invokeEvent(AppEvent.NoInternetEvent(false)) + return false } + + if (StorageComputer.spaceMissing(appInstall) > 0) { + Timber.d("Storage is not available for: ${appInstall.name} size: ${appInstall.appSize}") + storageNotificationManager.showNotEnoughSpaceNotification(appInstall) + appInstallComponents.appManagerWrapper.installationIssue(appInstall) + EventBus.invokeEvent(AppEvent.ErrorMessageEvent(R.string.not_enough_storage)) + return false + } + + return true } private suspend fun validateAgeLimit(appInstall: AppInstall): Boolean { -- GitLab From 13f4e8a5f2648dfeb3f64e41aef6cdad8364b649 Mon Sep 17 00:00:00 2001 From: dev-12 Date: Thu, 5 Feb 2026 01:12:07 +0530 Subject: [PATCH 08/13] refactor: return update outcomes and drop unused params MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Return per‑app results from the settings gate so callers can handle failures, and simplify the control flow by consolidating the permission/network checks into explicit success/failure branches while removing unused parameters. --- .../e/apps/install/updates/UpdatesWorker.kt | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/foundation/e/apps/install/updates/UpdatesWorker.kt b/app/src/main/java/foundation/e/apps/install/updates/UpdatesWorker.kt index bf58de278..db955cfe5 100644 --- a/app/src/main/java/foundation/e/apps/install/updates/UpdatesWorker.kt +++ b/app/src/main/java/foundation/e/apps/install/updates/UpdatesWorker.kt @@ -196,18 +196,20 @@ class UpdatesWorker @AssistedInject constructor( private suspend fun triggerUpdateProcessOnSettings( isConnectedToUnmeteredNetwork: Boolean, - appsNeededToUpdate: List, - authData: AuthData - ) { + appsNeededToUpdate: List + ): List> { + fun failedAllResponse() = appsNeededToUpdate.map { app -> Pair(app, false) } val hasStoragePermission = applicationContext.checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED - if ((!isAutoUpdate || automaticInstallEnabled) && hasStoragePermission) { - if (onlyOnUnmeteredNetwork && isConnectedToUnmeteredNetwork) { - startUpdateProcess(appsNeededToUpdate) - } else if (!onlyOnUnmeteredNetwork) { - startUpdateProcess(appsNeededToUpdate) - } + val canContinue = (!isAutoUpdate || automaticInstallEnabled) && hasStoragePermission + if (!canContinue) return failedAllResponse() + return if (onlyOnUnmeteredNetwork && isConnectedToUnmeteredNetwork) { + startUpdateProcess(appsNeededToUpdate) + } else if (!onlyOnUnmeteredNetwork) { + startUpdateProcess(appsNeededToUpdate) + } else { + failedAllResponse() } } -- GitLab From 55acdf58dff856a4fb0ca85ed71ae13977c87bd9 Mon Sep 17 00:00:00 2001 From: dev-12 Date: Thu, 5 Feb 2026 01:26:48 +0530 Subject: [PATCH 09/13] refactor: return update status and centralize retry logic Make checkForUpdates return a ResultStatus so callers can act on success/failure, and replace the scattered retry/auth logic with a single getAvailableUpdates + loadWithRetry path that always returns a value. --- .../e/apps/install/updates/UpdatesWorker.kt | 136 ++++++++---------- 1 file changed, 61 insertions(+), 75 deletions(-) diff --git a/app/src/main/java/foundation/e/apps/install/updates/UpdatesWorker.kt b/app/src/main/java/foundation/e/apps/install/updates/UpdatesWorker.kt index db955cfe5..bff6d4bf3 100644 --- a/app/src/main/java/foundation/e/apps/install/updates/UpdatesWorker.kt +++ b/app/src/main/java/foundation/e/apps/install/updates/UpdatesWorker.kt @@ -11,7 +11,6 @@ import androidx.work.CoroutineWorker import androidx.work.WorkInfo.State import androidx.work.WorkManager import androidx.work.WorkerParameters -import com.aurora.gplayapi.data.models.AuthData import dagger.assisted.Assisted import dagger.assisted.AssistedInject import foundation.e.apps.R @@ -109,89 +108,22 @@ class UpdatesWorker @AssistedInject constructor( return appLoungeDataStore.getLoginState() } - private suspend fun checkForUpdates() { + private suspend fun checkForUpdates(): ResultStatus { loadSettings() val isConnectedToUnMeteredNetwork = isConnectedToUnMeteredNetwork(applicationContext) - val appsNeededToUpdate = mutableListOf() - val user = getUser() - val loginState = getLoginState() - val authData = appLoungeDataStore.getAuthData() - val resultStatus: ResultStatus + val (appsNeededToUpdate, resultStatus) = loadWithRetry(emptyList(), ::getAvailableUpdates) - if (user in listOf(User.ANONYMOUS, User.GOOGLE)) { - /* - * Signifies valid Google user and valid auth data to update - * apps from Google Play store. - * The user check will be more useful in No-Google mode. - */ - val updateData = fetchUpdatesWithAuthRetry() - appsNeededToUpdate.addAll(updateData.first) - resultStatus = updateData.second - } else if (loginState != LoginState.UNAVAILABLE) { - /* - * If authData is null, update apps from cleanapk only. - */ - val updateData = updatesManagerRepository.getUpdatesOSS() - appsNeededToUpdate.addAll(updateData.first) - resultStatus = updateData.second - } else { - /* - * If login state is unavailable, don't do anything. - */ - Timber.e("Update is aborted for unavailable user!") - return + if (resultStatus != ResultStatus.OK) { + return resultStatus } + Timber.i("Updates found: ${appsNeededToUpdate.size}; $resultStatus") if (isAutoUpdate && shouldShowNotification) { handleNotification(appsNeededToUpdate.size, isConnectedToUnMeteredNetwork) } - if (resultStatus != ResultStatus.OK) { - manageRetry(resultStatus.toString()) - } else { - /* - * Show notification only if enabled. - * Issue: https://gitlab.e.foundation/e/backlog/-/issues/5376 - */ - retryCount = 0 - if (isAutoUpdate && shouldShowNotification) { - handleNotification(appsNeededToUpdate.size, isConnectedToUnMeteredNetwork) - } - - triggerUpdateProcessOnSettings( - isConnectedToUnMeteredNetwork, - appsNeededToUpdate, - authData, - ) - } - } - - private suspend fun fetchUpdatesWithAuthRetry(): Pair, ResultStatus> { - val updateData = updatesManagerRepository.getUpdates() - if (updateData.second == ResultStatus.OK) { - return updateData - } - - val refreshedAuth = authenticatorRepository.getValidatedAuthData().data - val shouldRetry = refreshedAuth != null - - return if (shouldRetry) updatesManagerRepository.getUpdates() else updateData - } - - private suspend fun manageRetry(extraMessage: String) { - retryCount++ - if (retryCount == 1) { - EventBus.invokeEvent(AppEvent.UpdateEvent(ResultSupreme.WorkError(ResultStatus.RETRY))) - } - - if (retryCount <= MAX_RETRY_COUNT) { - delay(DELAY_FOR_RETRY) - checkForUpdates() - } else { - val message = "Update is aborted after trying for $MAX_RETRY_COUNT times! message: $extraMessage" - Timber.e(message) - EventBus.invokeEvent(AppEvent.UpdateEvent(ResultSupreme.WorkError(ResultStatus.UNKNOWN))) - } + val jobsEnqueued = triggerUpdateProcessOnSettings(isConnectedToUnMeteredNetwork, appsNeededToUpdate) + return if (jobsEnqueued.all { it.second }) ResultStatus.OK else ResultStatus.UNKNOWN } private suspend fun triggerUpdateProcessOnSettings( @@ -213,6 +145,60 @@ class UpdatesWorker @AssistedInject constructor( } } + private suspend fun getAvailableUpdates(): Pair, ResultStatus> { + loadSettings() + val appsNeededToUpdate = mutableListOf() + val user = getUser() + val authData = authenticatorRepository.getValidatedAuthData().data + val resultStatus: ResultStatus + + if (user in listOf(User.ANONYMOUS, User.GOOGLE) && authData != null) { + /* + * Signifies valid Google user and valid auth data to update + * apps from Google Play store. + * The user check will be more useful in No-Google mode. + */ + val (apps, status) = updatesManagerRepository.getUpdates() + appsNeededToUpdate.addAll(apps) + resultStatus = status + } else if (user == User.NO_GOOGLE) { + /* + * If authData is null, update apps from cleanapk only. + */ + val (apps, status) = updatesManagerRepository.getUpdatesOSS() + appsNeededToUpdate.addAll(apps) + resultStatus = status + } else { + /* + * If user in UNAVAILABLE, don't do anything. + */ + Timber.e("Update is aborted for unavailable user!") + resultStatus = ResultStatus.UNKNOWN + } + + return Pair(appsNeededToUpdate, resultStatus) + } + + private suspend fun loadWithRetry( + defaultValue: T, + loadData: suspend () -> Pair + ): Pair { + while (retryCount <= MAX_RETRY_COUNT) { + retryCount++ + val (data, status) = loadData() + if (status == ResultStatus.OK) { + return Pair(data, status) + } + delay(DELAY_FOR_RETRY) + EventBus.invokeEvent(AppEvent.UpdateEvent(ResultSupreme.WorkError(ResultStatus.RETRY))) + } + + val message = "Update is aborted after trying for $MAX_RETRY_COUNT times!" + Timber.e(message) + EventBus.invokeEvent(AppEvent.UpdateEvent(ResultSupreme.WorkError(ResultStatus.UNKNOWN))) + return Pair(defaultValue, ResultStatus.UNKNOWN) + } + private fun handleNotification( numberOfAppsNeedUpdate: Int, isConnectedToUnmeteredNetwork: Boolean -- GitLab From f354891ec54f2b496b9e19269eca265d67cb1959 Mon Sep 17 00:00:00 2001 From: dev-12 Date: Thu, 5 Feb 2026 01:28:29 +0530 Subject: [PATCH 10/13] refactor: fail updates worker when pipeline steps fail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Treat system app fetch or update‑enqueue failures as hard failures so the WorkManager job reflects pipeline failures instead of masking them, and the system can reschedule promptly rather than waiting for the next auto‑update window. --- .../e/apps/install/updates/UpdatesWorker.kt | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/foundation/e/apps/install/updates/UpdatesWorker.kt b/app/src/main/java/foundation/e/apps/install/updates/UpdatesWorker.kt index bff6d4bf3..362901073 100644 --- a/app/src/main/java/foundation/e/apps/install/updates/UpdatesWorker.kt +++ b/app/src/main/java/foundation/e/apps/install/updates/UpdatesWorker.kt @@ -63,9 +63,18 @@ class UpdatesWorker @AssistedInject constructor( return Result.success() } - refreshBlockedAppList() - systemAppsUpdatesRepository.fetchUpdatableSystemApps(forceRefresh = true) - checkForUpdates() + if (isAutoUpdate) { + check(isAutoUpdate && blockedAppRepository.fetchUpdateOfAppWarningList()) { + "failed to update app blocklist" + } + } + + 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) @@ -77,12 +86,6 @@ class UpdatesWorker @AssistedInject constructor( } } - private suspend fun refreshBlockedAppList() { - if (isAutoUpdate) { - blockedAppRepository.fetchUpdateOfAppWarningList() - } - } - private suspend fun checkManualUpdateRunning(): Boolean { val workInfos = withContext(Dispatchers.IO) { -- GitLab From fc1ba43a763bb09940504ecb5184f24f64d9e212 Mon Sep 17 00:00:00 2001 From: dev-12 Date: Thu, 12 Feb 2026 12:32:34 +0530 Subject: [PATCH 11/13] test: add unit test for SystemAppsUpdatesRepository --- .../gitlab/SystemAppsUpdatesRepositoryTest.kt | 224 ++++++++++++++++++ 1 file changed, 224 insertions(+) create mode 100644 app/src/test/java/foundation/e/apps/data/gitlab/SystemAppsUpdatesRepositoryTest.kt diff --git a/app/src/test/java/foundation/e/apps/data/gitlab/SystemAppsUpdatesRepositoryTest.kt b/app/src/test/java/foundation/e/apps/data/gitlab/SystemAppsUpdatesRepositoryTest.kt new file mode 100644 index 000000000..591022362 --- /dev/null +++ b/app/src/test/java/foundation/e/apps/data/gitlab/SystemAppsUpdatesRepositoryTest.kt @@ -0,0 +1,224 @@ +/* + * Copyright (C) 2026 MURENA SAS + * + * 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.gitlab + +import android.content.Context +import foundation.e.apps.data.application.ApplicationDataManager +import foundation.e.apps.data.gitlab.models.SystemAppProject +import foundation.e.apps.install.pkg.AppLoungePackageManager +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.ResponseBody.Companion.toResponseBody +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import retrofit2.Response +import java.net.SocketTimeoutException + +class SystemAppsUpdatesRepositoryTest { + + @Test + fun fetchUpdatableSystemApps_cachesResponseWhenNotForced() = runTest { + val updatableSystemAppsApi = mockk() + val responseBody = listOf(SystemAppProject("com.example.app", 123)) + coEvery { updatableSystemAppsApi.getUpdatableSystemApps(any()) } returns + Response.success(responseBody) + + val repository = createRepository(updatableSystemAppsApi) + + val first = repository.fetchUpdatableSystemApps() + val second = repository.fetchUpdatableSystemApps() + + assertTrue(first.isSuccess()) + assertTrue(second.isSuccess()) + coVerify(exactly = 1) { updatableSystemAppsApi.getUpdatableSystemApps(any()) } + } + + @Test + fun fetchUpdatableSystemApps_returnsErrorOnEmptyBody() = runTest { + val updatableSystemAppsApi = mockk() + coEvery { updatableSystemAppsApi.getUpdatableSystemApps(any()) } returns + Response.success(emptyList()) + + val repository = createRepository(updatableSystemAppsApi) + + val result = repository.fetchUpdatableSystemApps() + + assertFalse(result.isSuccess()) + coVerify(exactly = 1) { updatableSystemAppsApi.getUpdatableSystemApps(any()) } + } + + @Test + fun fetchUpdatableSystemApps_returnsErrorOnHttpFailure() = runTest { + val updatableSystemAppsApi = mockk() + val errorBody = "server error".toResponseBody("text/plain".toMediaType()) + coEvery { updatableSystemAppsApi.getUpdatableSystemApps(any()) } returns + Response.error(500, errorBody) + + val repository = createRepository(updatableSystemAppsApi) + + val result = repository.fetchUpdatableSystemApps() + + assertFalse(result.isSuccess()) + coVerify(exactly = 1) { updatableSystemAppsApi.getUpdatableSystemApps(any()) } + } + + @Test + fun fetchUpdatableSystemApps_forceRefreshBypassesCache() = runTest { + val updatableSystemAppsApi = mockk() + val responseBody = listOf(SystemAppProject("com.example.app", 123)) + coEvery { updatableSystemAppsApi.getUpdatableSystemApps(any()) } returns + Response.success(responseBody) + + val repository = createRepository(updatableSystemAppsApi) + + val first = repository.fetchUpdatableSystemApps() + val second = repository.fetchUpdatableSystemApps(forceRefresh = true) + + assertTrue(first.isSuccess()) + assertTrue(second.isSuccess()) + coVerify(exactly = 2) { updatableSystemAppsApi.getUpdatableSystemApps(any()) } + } + + @Test + fun fetchUpdatableSystemApps_retriesAfterFailure() = runTest { + val updatableSystemAppsApi = mockk() + val errorBody = "server error".toResponseBody("text/plain".toMediaType()) + val responseBody = listOf(SystemAppProject("com.example.app", 123)) + coEvery { updatableSystemAppsApi.getUpdatableSystemApps(any()) } returnsMany listOf( + Response.error(500, errorBody), + Response.success(responseBody) + ) + + val repository = createRepository(updatableSystemAppsApi) + + val first = repository.fetchUpdatableSystemApps() + val second = repository.fetchUpdatableSystemApps() + + assertFalse(first.isSuccess()) + assertTrue(second.isSuccess()) + coVerify(exactly = 2) { updatableSystemAppsApi.getUpdatableSystemApps(any()) } + } + + @Test + fun fetchUpdatableSystemApps_emptyBodyStillErrorsWithForceRefresh() = runTest { + val updatableSystemAppsApi = mockk() + coEvery { updatableSystemAppsApi.getUpdatableSystemApps(any()) } returns + Response.success(emptyList()) + + val repository = createRepository(updatableSystemAppsApi) + + val result = repository.fetchUpdatableSystemApps(forceRefresh = true) + + assertFalse(result.isSuccess()) + coVerify(exactly = 1) { updatableSystemAppsApi.getUpdatableSystemApps(any()) } + } + + @Test + fun fetchUpdatableSystemApps_returnsTimeoutOnNetworkTimeout() = runTest { + val updatableSystemAppsApi = mockk() + coEvery { updatableSystemAppsApi.getUpdatableSystemApps(any()) } throws + SocketTimeoutException("timeout") + + val repository = createRepository(updatableSystemAppsApi) + + val result = repository.fetchUpdatableSystemApps() + + assertTrue(result.isTimeout()) + coVerify(exactly = 1) { updatableSystemAppsApi.getUpdatableSystemApps(any()) } + } + + @Test + fun fetchUpdatableSystemApps_usesCachedDataAfterForcedRefreshFailure() = runTest { + val updatableSystemAppsApi = mockk() + val responseBody = listOf(SystemAppProject("com.example.app", 123)) + val errorBody = "server error".toResponseBody("text/plain".toMediaType()) + coEvery { updatableSystemAppsApi.getUpdatableSystemApps(any()) } returnsMany listOf( + Response.success(responseBody), + Response.error(500, errorBody) + ) + + val repository = createRepository(updatableSystemAppsApi) + + val first = repository.fetchUpdatableSystemApps() + val forcedRefresh = repository.fetchUpdatableSystemApps(forceRefresh = true) + val third = repository.fetchUpdatableSystemApps() + + assertTrue(first.isSuccess()) + assertFalse(forcedRefresh.isSuccess()) + assertTrue(third.isSuccess()) + coVerify(exactly = 2) { updatableSystemAppsApi.getUpdatableSystemApps(any()) } + } + + @Test + fun fetchUpdatableSystemApps_retriesOnSequentialFailuresWithoutCaching() = runTest { + val updatableSystemAppsApi = mockk() + val errorBody = "server error".toResponseBody("text/plain".toMediaType()) + coEvery { updatableSystemAppsApi.getUpdatableSystemApps(any()) } returnsMany listOf( + Response.error(500, errorBody), + Response.error(500, errorBody) + ) + + val repository = createRepository(updatableSystemAppsApi) + + val first = repository.fetchUpdatableSystemApps() + val second = repository.fetchUpdatableSystemApps() + + assertFalse(first.isSuccess()) + assertFalse(second.isSuccess()) + coVerify(exactly = 2) { updatableSystemAppsApi.getUpdatableSystemApps(any()) } + } + + @Test + fun fetchUpdatableSystemApps_forceRefreshOverwritesCachedData() = runTest { + val updatableSystemAppsApi = mockk() + val firstResponse = listOf(SystemAppProject("com.example.app", 123)) + val secondResponse = listOf(SystemAppProject("com.example.other", 456)) + coEvery { updatableSystemAppsApi.getUpdatableSystemApps(any()) } returnsMany listOf( + Response.success(firstResponse), + Response.success(secondResponse) + ) + + val repository = createRepository(updatableSystemAppsApi) + + val first = repository.fetchUpdatableSystemApps() + val forced = repository.fetchUpdatableSystemApps(forceRefresh = true) + val third = repository.fetchUpdatableSystemApps() + + assertTrue(first.isSuccess()) + assertTrue(forced.isSuccess()) + assertTrue(third.isSuccess()) + coVerify(exactly = 2) { updatableSystemAppsApi.getUpdatableSystemApps(any()) } + } + + private fun createRepository( + updatableSystemAppsApi: UpdatableSystemAppsApi, + ): SystemAppsUpdatesRepository { + return SystemAppsUpdatesRepository( + context = mockk(relaxed = true), + updatableSystemAppsApi = updatableSystemAppsApi, + systemAppDefinitionApi = mockk(relaxed = true), + applicationDataManager = mockk(relaxed = true), + appLoungePackageManager = mockk(relaxed = true), + releaseInfoApi = mockk(relaxed = true), + ) + } +} -- GitLab From c63c5d08d747f96e18a155e09e10b820d36464fc Mon Sep 17 00:00:00 2001 From: dev-12 Date: Thu, 12 Feb 2026 13:22:51 +0530 Subject: [PATCH 12/13] test: cover UpdatesWorker loadWithRetry and startUpdateProcess flows --- .../e/apps/install/updates/UpdatesWorker.kt | 17 +- .../apps/install/updates/UpdatesWorkerTest.kt | 398 ++++++++++++++++++ 2 files changed, 410 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/foundation/e/apps/install/updates/UpdatesWorker.kt b/app/src/main/java/foundation/e/apps/install/updates/UpdatesWorker.kt index 362901073..6a4420cfd 100644 --- a/app/src/main/java/foundation/e/apps/install/updates/UpdatesWorker.kt +++ b/app/src/main/java/foundation/e/apps/install/updates/UpdatesWorker.kt @@ -5,6 +5,7 @@ import android.content.Context import android.content.pm.PackageManager import android.net.ConnectivityManager import android.net.NetworkCapabilities +import androidx.annotation.VisibleForTesting import androidx.hilt.work.HiltWorker import androidx.preference.PreferenceManager import androidx.work.CoroutineWorker @@ -46,7 +47,9 @@ class UpdatesWorker @AssistedInject constructor( companion object { const val IS_AUTO_UPDATE = "IS_AUTO_UPDATE" - private const val MAX_RETRY_COUNT = 10 + + @VisibleForTesting + const val MAX_RETRY_COUNT = 10 private const val DELAY_FOR_RETRY = 3000L } @@ -129,7 +132,8 @@ class UpdatesWorker @AssistedInject constructor( return if (jobsEnqueued.all { it.second }) ResultStatus.OK else ResultStatus.UNKNOWN } - private suspend fun triggerUpdateProcessOnSettings( + @VisibleForTesting + suspend fun triggerUpdateProcessOnSettings( isConnectedToUnmeteredNetwork: Boolean, appsNeededToUpdate: List ): List> { @@ -148,7 +152,8 @@ class UpdatesWorker @AssistedInject constructor( } } - private suspend fun getAvailableUpdates(): Pair, ResultStatus> { + @VisibleForTesting + suspend fun getAvailableUpdates(): Pair, ResultStatus> { loadSettings() val appsNeededToUpdate = mutableListOf() val user = getUser() @@ -182,7 +187,8 @@ class UpdatesWorker @AssistedInject constructor( return Pair(appsNeededToUpdate, resultStatus) } - private suspend fun loadWithRetry( + @VisibleForTesting + suspend fun loadWithRetry( defaultValue: T, loadData: suspend () -> Pair ): Pair { @@ -218,7 +224,8 @@ class UpdatesWorker @AssistedInject constructor( } // returns list of Pair(app, status(success|failed)) - private suspend fun startUpdateProcess(appsNeededToUpdate: List): List> { + @VisibleForTesting + suspend fun startUpdateProcess(appsNeededToUpdate: List): List> { val response = mutableListOf>() val authData = authenticatorRepository.getValidatedAuthData() val isNotLoggedIntoPersonalAccount = !authData.isValidData() || authData.data?.isAnonymous == true diff --git a/app/src/test/java/foundation/e/apps/install/updates/UpdatesWorkerTest.kt b/app/src/test/java/foundation/e/apps/install/updates/UpdatesWorkerTest.kt index 263d08c99..8c87397e7 100644 --- a/app/src/test/java/foundation/e/apps/install/updates/UpdatesWorkerTest.kt +++ b/app/src/test/java/foundation/e/apps/install/updates/UpdatesWorkerTest.kt @@ -48,11 +48,13 @@ import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every import io.mockk.mockk +import io.mockk.spyk import kotlinx.coroutines.test.runTest import org.junit.Test import org.junit.runner.RunWith import org.mockito.kotlin.any import org.mockito.kotlin.mock +import org.mockito.kotlin.never import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever @@ -143,6 +145,402 @@ class UpdatesWorkerTest { coVerify { storeAuthenticator.login() } } + @Test + fun doWork_retriesUpToMaxAndFailsWhenUpdatesNeverOk() = runTest { + val dataStoreContext = RuntimeEnvironment.getApplication() + val workerContext = mock() + val sharedPreferences = mock() + val connectivityManager = mock() + val network = mock() + val networkCapabilities = mock() + val notificationManager = mock() + val params = mock() + val updatesManagerRepository = mock() + val appLoungeDataStore = createDataStore(dataStoreContext) + val storeAuthenticator = mockk() + val authenticatorRepository = + AuthenticatorRepository(listOf(storeAuthenticator), appLoungeDataStore) + val appInstallProcessor = mock() + val blockedAppRepository = mock() + val systemAppsUpdatesRepository = mock() + val authData = AuthData(email = "user@example.com") + val applications = listOf() + + val inputData = Data.Builder() + .putBoolean(UpdatesWorker.IS_AUTO_UPDATE, false) + .build() + + whenever(workerContext.applicationContext).thenReturn(workerContext) + whenever(workerContext.getSharedPreferences(any(), any())).thenReturn(sharedPreferences) + whenever(workerContext.getString(any())).thenReturn("key") + whenever(workerContext.getSystemService(Context.CONNECTIVITY_SERVICE)).thenReturn( + connectivityManager + ) + whenever(workerContext.getSystemService(Context.NOTIFICATION_SERVICE)).thenReturn( + notificationManager + ) + whenever(workerContext.checkSelfPermission(any())).thenReturn(android.content.pm.PackageManager.PERMISSION_GRANTED) + whenever(connectivityManager.activeNetwork).thenReturn(network) + whenever(connectivityManager.getNetworkCapabilities(network)).thenReturn(networkCapabilities) + whenever(networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)).thenReturn( + true + ) + whenever(sharedPreferences.getBoolean(any(), any())).thenReturn(true) + + appLoungeDataStore.destroyCredentials() + appLoungeDataStore.saveUserType(User.GOOGLE) + appLoungeDataStore.saveAuthData(authData) + + whenever(params.inputData).thenReturn(inputData) + whenever(updatesManagerRepository.getUpdates()).thenReturn( + Pair( + applications, + ResultStatus.RETRY + ) + ) + whenever(updatesManagerRepository.getUpdatesOSS()).thenReturn( + Pair( + applications, + ResultStatus.OK + ) + ) + whenever(systemAppsUpdatesRepository.fetchUpdatableSystemApps(true)) + .thenReturn(ResultSupreme.Success(Unit)) + + every { storeAuthenticator.storeType } returns StoreType.PLAY_STORE + every { storeAuthenticator.isStoreActive() } returns true + coEvery { storeAuthenticator.logout() } returns Unit + coEvery { storeAuthenticator.login() } returns StoreAuthResult( + AuthObject.GPlayAuth(ResultSupreme.Success(authData), User.GOOGLE), + authData + ) + + val worker = UpdatesWorker( + workerContext, + params, + updatesManagerRepository, + appLoungeDataStore, + authenticatorRepository, + appInstallProcessor, + blockedAppRepository, + systemAppsUpdatesRepository + ) + + val result = worker.doWork() + + assertThat(result).isEqualTo(androidx.work.ListenableWorker.Result.failure()) + verify(updatesManagerRepository, times(UpdatesWorker.MAX_RETRY_COUNT.plus(1))).getUpdates() + verify(updatesManagerRepository, never()).getUpdatesOSS() + verify(appInstallProcessor, never()).initAppInstall(any(), any()) + } + + @Test + fun loadWithRetry_callsGetAvailableUpdatesMaxTimes() = runTest { + val dataStoreContext = RuntimeEnvironment.getApplication() + val workerContext = mock() + val sharedPreferences = mock() + val connectivityManager = mock() + val network = mock() + val networkCapabilities = mock() + val notificationManager = mock() + val params = mock() + val updatesManagerRepository = mock() + val appLoungeDataStore = createDataStore(dataStoreContext) + val storeAuthenticator = mockk() + val authenticatorRepository = + AuthenticatorRepository(listOf(storeAuthenticator), appLoungeDataStore) + val appInstallProcessor = mock() + val blockedAppRepository = mock() + val systemAppsUpdatesRepository = mock() + + whenever(workerContext.applicationContext).thenReturn(workerContext) + whenever(workerContext.getSharedPreferences(any(), any())).thenReturn(sharedPreferences) + whenever(workerContext.getString(any())).thenReturn("key") + whenever(workerContext.getSystemService(Context.CONNECTIVITY_SERVICE)).thenReturn( + connectivityManager + ) + whenever(workerContext.getSystemService(Context.NOTIFICATION_SERVICE)).thenReturn( + notificationManager + ) + whenever(workerContext.checkSelfPermission(any())).thenReturn(android.content.pm.PackageManager.PERMISSION_GRANTED) + whenever(connectivityManager.activeNetwork).thenReturn(network) + whenever(connectivityManager.getNetworkCapabilities(network)).thenReturn(networkCapabilities) + whenever(networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)).thenReturn( + true + ) + whenever(sharedPreferences.getBoolean(any(), any())).thenReturn(true) + + val worker = spyk( + UpdatesWorker( + workerContext, + params, + updatesManagerRepository, + appLoungeDataStore, + authenticatorRepository, + appInstallProcessor, + blockedAppRepository, + systemAppsUpdatesRepository + ) + ) + + coEvery { worker.getAvailableUpdates() } returns Pair(emptyList(), ResultStatus.RETRY) + + val result = worker.loadWithRetry(emptyList(), worker::getAvailableUpdates) + + assertThat(result.second).isEqualTo(ResultStatus.UNKNOWN) + coVerify(exactly = UpdatesWorker.MAX_RETRY_COUNT.plus(1)) { worker.getAvailableUpdates() } + } + + @Test + fun loadWithRetry_stopsAfterFirstSuccess() = runTest { + val dataStoreContext = RuntimeEnvironment.getApplication() + val workerContext = mock() + val sharedPreferences = mock() + val connectivityManager = mock() + val network = mock() + val networkCapabilities = mock() + val notificationManager = mock() + val params = mock() + val updatesManagerRepository = mock() + val appLoungeDataStore = createDataStore(dataStoreContext) + val storeAuthenticator = mockk() + val authenticatorRepository = + AuthenticatorRepository(listOf(storeAuthenticator), appLoungeDataStore) + val appInstallProcessor = mock() + val blockedAppRepository = mock() + val systemAppsUpdatesRepository = mock() + + whenever(workerContext.applicationContext).thenReturn(workerContext) + whenever(workerContext.getSharedPreferences(any(), any())).thenReturn(sharedPreferences) + whenever(workerContext.getString(any())).thenReturn("key") + whenever(workerContext.getSystemService(Context.CONNECTIVITY_SERVICE)).thenReturn( + connectivityManager + ) + whenever(workerContext.getSystemService(Context.NOTIFICATION_SERVICE)).thenReturn( + notificationManager + ) + whenever(workerContext.checkSelfPermission(any())).thenReturn(android.content.pm.PackageManager.PERMISSION_GRANTED) + whenever(connectivityManager.activeNetwork).thenReturn(network) + whenever(connectivityManager.getNetworkCapabilities(network)).thenReturn(networkCapabilities) + whenever(networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)).thenReturn( + true + ) + whenever(sharedPreferences.getBoolean(any(), any())).thenReturn(true) + + val worker = spyk( + UpdatesWorker( + workerContext, + params, + updatesManagerRepository, + appLoungeDataStore, + authenticatorRepository, + appInstallProcessor, + blockedAppRepository, + systemAppsUpdatesRepository + ) + ) + + coEvery { worker.getAvailableUpdates() } returns Pair(emptyList(), ResultStatus.OK) + + val result = worker.loadWithRetry(emptyList(), worker::getAvailableUpdates) + + assertThat(result.second).isEqualTo(ResultStatus.OK) + coVerify(exactly = 1) { worker.getAvailableUpdates() } + } + + @Test + fun startUpdateProcess_skipsPaidAppsWhenAnonymousUser() = runTest { + val dataStoreContext = RuntimeEnvironment.getApplication() + val workerContext = mock() + val params = mock() + val updatesManagerRepository = mock() + val appLoungeDataStore = createDataStore(dataStoreContext) + val storeAuthenticator = mockk() + val authenticatorRepository = + AuthenticatorRepository(listOf(storeAuthenticator), appLoungeDataStore) + val appInstallProcessor = mock() + val blockedAppRepository = mock() + val systemAppsUpdatesRepository = mock() + + every { storeAuthenticator.storeType } returns StoreType.PLAY_STORE + coEvery { storeAuthenticator.logout() } returns Unit + val anonymousAuthData = AuthData(email = "anon@example.com", isAnonymous = true) + coEvery { storeAuthenticator.login() } returns StoreAuthResult( + AuthObject.GPlayAuth(ResultSupreme.Success(anonymousAuthData), User.ANONYMOUS), + null + ) + + val worker = UpdatesWorker( + workerContext, + params, + updatesManagerRepository, + appLoungeDataStore, + authenticatorRepository, + appInstallProcessor, + blockedAppRepository, + systemAppsUpdatesRepository + ) + + val paidApp = Application(name = "Paid", isFree = false) + val freeApp = Application(name = "Free", isFree = true) + whenever(appInstallProcessor.initAppInstall(freeApp, true)).thenReturn(true) + + val result = worker.startUpdateProcess(listOf(paidApp, freeApp)) + + assertThat(result).containsExactly( + Pair(paidApp, false), + Pair(freeApp, true) + ) + verify(appInstallProcessor, times(1)).initAppInstall(freeApp, true) + verify(appInstallProcessor, times(0)).initAppInstall(paidApp, true) + } + + @Test + fun startUpdateProcess_installsPaidAppsWhenGoogleUser() = runTest { + val dataStoreContext = RuntimeEnvironment.getApplication() + val workerContext = mock() + val params = mock() + val updatesManagerRepository = mock() + val appLoungeDataStore = createDataStore(dataStoreContext) + val storeAuthenticator = mockk() + val authenticatorRepository = + AuthenticatorRepository(listOf(storeAuthenticator), appLoungeDataStore) + val appInstallProcessor = mock() + val blockedAppRepository = mock() + val systemAppsUpdatesRepository = mock() + val authData = AuthData(email = "user@example.com", isAnonymous = false) + + every { storeAuthenticator.storeType } returns StoreType.PLAY_STORE + coEvery { storeAuthenticator.logout() } returns Unit + coEvery { storeAuthenticator.login() } returns StoreAuthResult( + AuthObject.GPlayAuth(ResultSupreme.Success(authData), User.GOOGLE), + null + ) + + val worker = UpdatesWorker( + workerContext, + params, + updatesManagerRepository, + appLoungeDataStore, + authenticatorRepository, + appInstallProcessor, + blockedAppRepository, + systemAppsUpdatesRepository + ) + + val paidApp = Application(name = "Paid", isFree = false) + whenever(appInstallProcessor.initAppInstall(paidApp, true)).thenReturn(false) + + val result = worker.startUpdateProcess(listOf(paidApp)) + + assertThat(result).containsExactly(Pair(paidApp, false)) + verify(appInstallProcessor, times(1)).initAppInstall(paidApp, true) + } + + @Test + fun triggerUpdateProcessOnSettings_skipsWhenAutoInstallDisabled() = runTest { + val workerContext = mock() + val params = mock() + val updatesManagerRepository = mock() + val appLoungeDataStore = createDataStore(RuntimeEnvironment.getApplication()) + val storeAuthenticator = mockk() + val authenticatorRepository = + AuthenticatorRepository(listOf(storeAuthenticator), appLoungeDataStore) + val appInstallProcessor = mock() + val blockedAppRepository = mock() + val systemAppsUpdatesRepository = mock() + + whenever(workerContext.applicationContext).thenReturn(workerContext) + whenever(workerContext.checkSelfPermission(any())) + .thenReturn(android.content.pm.PackageManager.PERMISSION_GRANTED) + + val worker = spyk( + UpdatesWorker( + workerContext, + params, + updatesManagerRepository, + appLoungeDataStore, + authenticatorRepository, + appInstallProcessor, + blockedAppRepository, + systemAppsUpdatesRepository + ) + ) + + worker.javaClass.getDeclaredField("isAutoUpdate").apply { + isAccessible = true + setBoolean(worker, true) + } + worker.javaClass.getDeclaredField("automaticInstallEnabled").apply { + isAccessible = true + setBoolean(worker, false) + } + worker.javaClass.getDeclaredField("onlyOnUnmeteredNetwork").apply { + isAccessible = true + setBoolean(worker, true) + } + + val apps = listOf(Application(name = "App1")) + val result = worker.triggerUpdateProcessOnSettings(true, apps) + + assertThat(result).containsExactly(Pair(apps[0], false)) + coVerify(exactly = 0) { worker.startUpdateProcess(any()) } + } + + @Test + fun triggerUpdateProcessOnSettings_callsStartWhenAllowed() = runTest { + val workerContext = mock() + val params = mock() + val updatesManagerRepository = mock() + val appLoungeDataStore = createDataStore(RuntimeEnvironment.getApplication()) + val storeAuthenticator = mockk() + val authenticatorRepository = + AuthenticatorRepository(listOf(storeAuthenticator), appLoungeDataStore) + val appInstallProcessor = mock() + val blockedAppRepository = mock() + val systemAppsUpdatesRepository = mock() + + whenever(workerContext.applicationContext).thenReturn(workerContext) + whenever(workerContext.checkSelfPermission(any())) + .thenReturn(android.content.pm.PackageManager.PERMISSION_GRANTED) + + val worker = spyk( + UpdatesWorker( + workerContext, + params, + updatesManagerRepository, + appLoungeDataStore, + authenticatorRepository, + appInstallProcessor, + blockedAppRepository, + systemAppsUpdatesRepository + ) + ) + + worker.javaClass.getDeclaredField("isAutoUpdate").apply { + isAccessible = true + setBoolean(worker, true) + } + worker.javaClass.getDeclaredField("automaticInstallEnabled").apply { + isAccessible = true + setBoolean(worker, true) + } + worker.javaClass.getDeclaredField("onlyOnUnmeteredNetwork").apply { + isAccessible = true + setBoolean(worker, true) + } + + val apps = listOf(Application(name = "App1")) + val expected = listOf(Pair(apps[0], true)) + coEvery { worker.startUpdateProcess(apps) } returns expected + + val result = worker.triggerUpdateProcessOnSettings(true, apps) + + assertThat(result).isEqualTo(expected) + coVerify(exactly = 1) { worker.startUpdateProcess(apps) } + } + + private fun createDataStore(context: Context): AppLoungeDataStore { val json = kotlinx.serialization.json.Json { ignoreUnknownKeys = true } return AppLoungeDataStore(context, json) -- GitLab From a2da54b977b14a7335ba2bdc64e9c6ce888afd9a Mon Sep 17 00:00:00 2001 From: dev-12 Date: Thu, 12 Feb 2026 14:01:59 +0530 Subject: [PATCH 13/13] test: add AppInstallProcessor.canEnqueue coverage --- .../workmanager/AppInstallProcessor.kt | 4 +- .../AppInstallProcessorTest.kt | 197 ++++++++++++++++++ 2 files changed, 200 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/foundation/e/apps/install/workmanager/AppInstallProcessor.kt b/app/src/main/java/foundation/e/apps/install/workmanager/AppInstallProcessor.kt index 92a77826a..198a18c4d 100644 --- a/app/src/main/java/foundation/e/apps/install/workmanager/AppInstallProcessor.kt +++ b/app/src/main/java/foundation/e/apps/install/workmanager/AppInstallProcessor.kt @@ -19,6 +19,7 @@ package foundation.e.apps.install.workmanager import android.content.Context +import androidx.annotation.VisibleForTesting import com.aurora.gplayapi.exceptions.InternalException import dagger.hilt.android.qualifiers.ApplicationContext import foundation.e.apps.R @@ -152,7 +153,8 @@ class AppInstallProcessor @Inject constructor( } } - private suspend fun canEnqueue(appInstall: AppInstall): Boolean { + @VisibleForTesting + suspend fun canEnqueue(appInstall: AppInstall): Boolean { if (appInstall.type != Type.PWA && !updateDownloadUrls(appInstall)) { return false } diff --git a/app/src/test/java/foundation/e/apps/installProcessor/AppInstallProcessorTest.kt b/app/src/test/java/foundation/e/apps/installProcessor/AppInstallProcessorTest.kt index 0ff701f54..67544b5c9 100644 --- a/app/src/test/java/foundation/e/apps/installProcessor/AppInstallProcessorTest.kt +++ b/app/src/test/java/foundation/e/apps/installProcessor/AppInstallProcessorTest.kt @@ -19,6 +19,9 @@ package foundation.e.apps.installProcessor import android.content.Context +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities import androidx.arch.core.executor.testing.InstantTaskExecutorRule import foundation.e.apps.data.ResultSupreme import foundation.e.apps.data.enums.Status @@ -26,6 +29,7 @@ import foundation.e.apps.data.fdroid.FDroidRepository import foundation.e.apps.data.application.ApplicationRepository import foundation.e.apps.data.enums.ResultStatus import foundation.e.apps.data.install.AppInstallRepository +import foundation.e.apps.data.install.AppManagerWrapper import foundation.e.apps.data.install.AppManager import foundation.e.apps.data.install.models.AppInstall import foundation.e.apps.data.preference.AppLoungeDataStore @@ -34,7 +38,13 @@ import foundation.e.apps.domain.model.ContentRatingValidity import foundation.e.apps.install.AppInstallComponents import foundation.e.apps.install.notification.StorageNotificationManager import foundation.e.apps.install.workmanager.AppInstallProcessor +import foundation.e.apps.utils.StorageComputer import foundation.e.apps.util.MainCoroutineRule +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.unmockkObject import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals @@ -45,6 +55,8 @@ import org.junit.Test import org.mockito.Mock import org.mockito.Mockito import org.mockito.MockitoAnnotations +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever @OptIn(ExperimentalCoroutinesApi::class) class AppInstallProcessorTest { @@ -207,6 +219,157 @@ class AppInstallProcessorTest { assertEquals("processInstall", finalFusedDownload, null) } + @Test + fun canEnqueue_returnsTrueWhenAllChecksPass() = runTest { + val appInstall = AppInstall( + type = foundation.e.apps.data.enums.Type.PWA, + id = "123", + status = Status.AWAITING, + downloadURLList = mutableListOf("apk"), + packageName = "com.example.app" + ) + + val appManagerWrapper = mockk(relaxed = true) + val processor = createProcessorForCanEnqueue(appManagerWrapper) + + mockkObject(StorageComputer) + try { + coEvery { appManagerWrapper.addDownload(appInstall) } returns true + Mockito.`when`(validateAppAgeRatingUseCase(appInstall)) + .thenReturn(ResultSupreme.create(ResultStatus.OK, ContentRatingValidity(true))) + everyNetworkAvailable() + io.mockk.every { StorageComputer.spaceMissing(appInstall) } returns 0 + + val result = processor.canEnqueue(appInstall) + + assertTrue(result) + coVerify { appManagerWrapper.addDownload(appInstall) } + } finally { + unmockkObject(StorageComputer) + } + } + + @Test + fun canEnqueue_returnsFalseWhenNetworkUnavailable() = runTest { + val appInstall = AppInstall( + type = foundation.e.apps.data.enums.Type.PWA, + id = "123", + status = Status.AWAITING, + downloadURLList = mutableListOf("apk"), + packageName = "com.example.app" + ) + + val appManagerWrapper = mockk(relaxed = true) + val processor = createProcessorForCanEnqueue(appManagerWrapper) + + mockkObject(StorageComputer) + try { + coEvery { appManagerWrapper.addDownload(appInstall) } returns true + Mockito.`when`(validateAppAgeRatingUseCase(appInstall)) + .thenReturn(ResultSupreme.create(ResultStatus.OK, ContentRatingValidity(true))) + everyNetworkUnavailable() + io.mockk.every { StorageComputer.spaceMissing(appInstall) } returns 0 + + val result = processor.canEnqueue(appInstall) + + assertEquals(false, result) + coVerify { appManagerWrapper.installationIssue(appInstall) } + } finally { + unmockkObject(StorageComputer) + } + } + + @Test + fun canEnqueue_returnsFalseWhenStorageMissing() = runTest { + val appInstall = AppInstall( + type = foundation.e.apps.data.enums.Type.PWA, + id = "123", + status = Status.AWAITING, + downloadURLList = mutableListOf("apk"), + packageName = "com.example.app" + ) + + val appManagerWrapper = mockk(relaxed = true) + val processor = createProcessorForCanEnqueue(appManagerWrapper) + + mockkObject(StorageComputer) + try { + coEvery { appManagerWrapper.addDownload(appInstall) } returns true + Mockito.`when`(validateAppAgeRatingUseCase(appInstall)) + .thenReturn(ResultSupreme.create(ResultStatus.OK, ContentRatingValidity(true))) + everyNetworkAvailable() + io.mockk.every { StorageComputer.spaceMissing(appInstall) } returns 100L + + val result = processor.canEnqueue(appInstall) + + assertEquals(false, result) + Mockito.verify(storageNotificationManager).showNotEnoughSpaceNotification(appInstall) + coVerify { appManagerWrapper.installationIssue(appInstall) } + } finally { + unmockkObject(StorageComputer) + } + } + + @Test + fun canEnqueue_returnsFalseWhenAddDownloadFails() = runTest { + val appInstall = AppInstall( + type = foundation.e.apps.data.enums.Type.PWA, + id = "123", + status = Status.AWAITING, + downloadURLList = mutableListOf("apk"), + packageName = "com.example.app" + ) + + val appManagerWrapper = mockk(relaxed = true) + val processor = createProcessorForCanEnqueue(appManagerWrapper) + + mockkObject(StorageComputer) + try { + coEvery { appManagerWrapper.addDownload(appInstall) } returns false + Mockito.`when`(validateAppAgeRatingUseCase(appInstall)) + .thenReturn(ResultSupreme.create(ResultStatus.OK, ContentRatingValidity(true))) + everyNetworkAvailable() + io.mockk.every { StorageComputer.spaceMissing(appInstall) } returns 0L + + val result = processor.canEnqueue(appInstall) + + assertEquals(false, result) + coVerify(exactly = 0) { appManagerWrapper.installationIssue(appInstall) } + } finally { + unmockkObject(StorageComputer) + } + } + + @Test + fun canEnqueue_returnsFalseWhenAgeLimitInvalid() = runTest { + val appInstall = AppInstall( + type = foundation.e.apps.data.enums.Type.PWA, + id = "123", + status = Status.AWAITING, + downloadURLList = mutableListOf("apk"), + packageName = "com.example.app" + ) + + val appManagerWrapper = mockk(relaxed = true) + val processor = createProcessorForCanEnqueue(appManagerWrapper) + + mockkObject(StorageComputer) + try { + coEvery { appManagerWrapper.addDownload(appInstall) } returns true + Mockito.`when`(validateAppAgeRatingUseCase(appInstall)) + .thenReturn(ResultSupreme.create(ResultStatus.UNKNOWN, ContentRatingValidity(false))) + everyNetworkAvailable() + io.mockk.every { StorageComputer.spaceMissing(appInstall) } returns 0L + + val result = processor.canEnqueue(appInstall) + + assertEquals(false, result) + coVerify { appManagerWrapper.cancelDownload(appInstall) } + } finally { + unmockkObject(StorageComputer) + } + } + private suspend fun runProcessInstall(appInstall: AppInstall): AppInstall? { appInstallProcessor.processInstall(appInstall.id, false) { // _ignored_ @@ -223,4 +386,38 @@ class AppInstallProcessorTest { downloadURLList = downloadUrlList ?: mutableListOf("apk1", "apk2"), packageName = packageName ?: "com.unit.test" ) + + private fun createProcessorForCanEnqueue( + appManagerWrapper: AppManagerWrapper + ): AppInstallProcessor { + val appInstallRepository = AppInstallRepository(FakeAppInstallDAO()) + val appInstallComponents = AppInstallComponents(appInstallRepository, appManagerWrapper) + return AppInstallProcessor( + context, + appInstallComponents, + applicationRepository, + validateAppAgeRatingUseCase, + appLoungeDataStore, + storageNotificationManager + ) + } + + private fun everyNetworkAvailable() { + val connectivityManager = mock() + val network = mock() + val networkCapabilities = mock() + whenever(context.getSystemService(ConnectivityManager::class.java)).thenReturn(connectivityManager) + whenever(connectivityManager.activeNetwork).thenReturn(network) + whenever(connectivityManager.getNetworkCapabilities(network)).thenReturn(networkCapabilities) + whenever(networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)).thenReturn(true) + whenever(networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)).thenReturn(true) + } + + private fun everyNetworkUnavailable() { + val connectivityManager = mock() + val network = mock() + whenever(context.getSystemService(ConnectivityManager::class.java)).thenReturn(connectivityManager) + whenever(connectivityManager.activeNetwork).thenReturn(network) + whenever(connectivityManager.getNetworkCapabilities(network)).thenReturn(null) + } } -- GitLab