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 ac068159b5d4a64d317a99b91838b7db08e4e07d..9071df854ea57d4b6dfeab956ac896d52e348997 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/main/java/foundation/e/apps/install/updates/UpdatesWorker.kt b/app/src/main/java/foundation/e/apps/install/updates/UpdatesWorker.kt index 032344b87c2dc895d403bee4c2c66f87f75827f8..6a4420cfdac28d6b2ac1b96a7e609dd958ce4c56 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,13 +5,13 @@ 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 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 @@ -47,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 } @@ -64,9 +66,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) @@ -78,12 +89,6 @@ class UpdatesWorker @AssistedInject constructor( } } - private suspend fun refreshBlockedAppList() { - if (isAutoUpdate) { - blockedAppRepository.fetchUpdateOfAppWarningList() - } - } - private suspend fun checkManualUpdateRunning(): Boolean { val workInfos = withContext(Dispatchers.IO) { @@ -109,106 +114,98 @@ class UpdatesWorker @AssistedInject constructor( return appLoungeDataStore.getLoginState() } - private suspend fun checkForUpdates() { + private suspend fun checkForUpdates(): ResultStatus { loadSettings() val isConnectedToUnMeteredNetwork = isConnectedToUnMeteredNetwork(applicationContext) + val (appsNeededToUpdate, resultStatus) = loadWithRetry(emptyList(), ::getAvailableUpdates) + + if (resultStatus != ResultStatus.OK) { + return resultStatus + } + + Timber.i("Updates found: ${appsNeededToUpdate.size}; $resultStatus") + if (isAutoUpdate && shouldShowNotification) { + handleNotification(appsNeededToUpdate.size, isConnectedToUnMeteredNetwork) + } + + val jobsEnqueued = triggerUpdateProcessOnSettings(isConnectedToUnMeteredNetwork, appsNeededToUpdate) + return if (jobsEnqueued.all { it.second }) ResultStatus.OK else ResultStatus.UNKNOWN + } + + @VisibleForTesting + suspend fun triggerUpdateProcessOnSettings( + 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 if (onlyOnUnmeteredNetwork && isConnectedToUnmeteredNetwork) { + startUpdateProcess(appsNeededToUpdate) + } else if (!onlyOnUnmeteredNetwork) { + startUpdateProcess(appsNeededToUpdate) + } else { + failedAllResponse() + } + } + + @VisibleForTesting + suspend fun getAvailableUpdates(): Pair, ResultStatus> { + loadSettings() val appsNeededToUpdate = mutableListOf() val user = getUser() - val loginState = getLoginState() - val authData = appLoungeDataStore.getAuthData() + val authData = authenticatorRepository.getValidatedAuthData().data val resultStatus: ResultStatus - if (user in listOf(User.ANONYMOUS, User.GOOGLE)) { + 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 updateData = fetchUpdatesWithAuthRetry() - appsNeededToUpdate.addAll(updateData.first) - resultStatus = updateData.second - } else if (loginState != LoginState.UNAVAILABLE) { + 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 updateData = updatesManagerRepository.getUpdatesOSS() - appsNeededToUpdate.addAll(updateData.first) - resultStatus = updateData.second + val (apps, status) = updatesManagerRepository.getUpdatesOSS() + appsNeededToUpdate.addAll(apps) + resultStatus = status } else { /* - * If login state is unavailable, don't do anything. + * If user in UNAVAILABLE, don't do anything. */ Timber.e("Update is aborted for unavailable user!") - return - } - Timber.i("Updates found: ${appsNeededToUpdate.size}; $resultStatus") - if (isAutoUpdate && shouldShowNotification) { - handleNotification(appsNeededToUpdate.size, isConnectedToUnMeteredNetwork) + resultStatus = ResultStatus.UNKNOWN } - 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, - ) - } + return Pair(appsNeededToUpdate, resultStatus) } - 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) { + @VisibleForTesting + 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) - 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))) + EventBus.invokeEvent(AppEvent.UpdateEvent(ResultSupreme.WorkError(ResultStatus.RETRY))) } - } - private suspend fun triggerUpdateProcessOnSettings( - isConnectedToUnmeteredNetwork: Boolean, - appsNeededToUpdate: List, - authData: AuthData - ) { - val hasStoragePermission = - applicationContext.checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) == - PackageManager.PERMISSION_GRANTED - if ((!isAutoUpdate || automaticInstallEnabled) && hasStoragePermission) { - if (onlyOnUnmeteredNetwork && isConnectedToUnmeteredNetwork) { - startUpdateProcess(appsNeededToUpdate, authData) - } else if (!onlyOnUnmeteredNetwork) { - startUpdateProcess(appsNeededToUpdate, authData) - } - } + 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( @@ -226,17 +223,22 @@ class UpdatesWorker @AssistedInject constructor( } } - private suspend fun startUpdateProcess( - appsNeededToUpdate: List, - authData: AuthData - ) { - appsNeededToUpdate.forEach { fusedApp -> - if (!fusedApp.isFree && authData.isAnonymous) { - return@forEach + // returns list of Pair(app, status(success|failed)) + @VisibleForTesting + 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 && isNotLoggedIntoPersonalAccount) + if (shouldSkip.or(isStopped)) { // respect the stop signal as well + response.add(Pair(fusedApp, false)) + continue } - - appInstallProcessor.initAppInstall(fusedApp, true) + val status = appInstallProcessor.initAppInstall(fusedApp, true) + response.add(Pair(fusedApp, status)) } + return response } private fun loadSettings() { 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 d7e933e05c569d0bdb369e1b1d5addc71dda2731..198a18c4dfc802562386d09438023ecbdf52e286 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 @@ -85,7 +86,7 @@ class AppInstallProcessor @Inject constructor( suspend fun initAppInstall( application: Application, isAnUpdate: Boolean = false - ) { + ): Boolean { val appInstall = AppInstall( application._id, application.source, @@ -113,7 +114,7 @@ class AppInstallProcessor @Inject constructor( application.status == Status.UPDATABLE || appInstallComponents.appManagerWrapper.isFusedDownloadInstalled(appInstall) - enqueueFusedDownload(appInstall, isUpdate, application.isSystemApp) + return enqueueFusedDownload(appInstall, isUpdate, application.isSystemApp) } /** @@ -127,8 +128,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 +138,51 @@ 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 + } + } + + @VisibleForTesting + 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 { @@ -270,7 +283,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 +319,7 @@ class AppInstallProcessor @Inject constructor( ) } - runInForeground?.invoke(it.name) + runInForeground.invoke(it.name) startAppInstallationProcess(it) } 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 42de78741a2ab2ff50f410fcb73a2e39ffd077c3..0344e45eff580f3b4dd1402274b3b8c063780bbe 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 { 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 0000000000000000000000000000000000000000..5910223622d3ebecd811b8c098dcdec00339cfdc --- /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), + ) + } +} 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 f069f13d15dd0e0c3c617122a568edf28675eaaf..8c87397e7ed6ea965227f9c2a91b03618616e14c 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 @@ -124,7 +126,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, @@ -142,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) 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 98c23a5250ece73ecdff241053b97da8c6f3edd2..67544b5c95ec0753d5e9e9fdabc8bbb067c146d6 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,8 +219,161 @@ 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) + appInstallProcessor.processInstall(appInstall.id, false) { + // _ignored_ + } return fakeFusedDownloadDAO.getDownloadById(appInstall.id) } @@ -221,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) + } }