diff --git a/app/src/main/java/foundation/e/apps/AppLoungeApplication.kt b/app/src/main/java/foundation/e/apps/AppLoungeApplication.kt index 98b7056d47c10a915b3d18564dd14ba0165e4609..acaa5d1a19869aee077f2a24f9a3fac6eeca0461 100644 --- a/app/src/main/java/foundation/e/apps/AppLoungeApplication.kt +++ b/app/src/main/java/foundation/e/apps/AppLoungeApplication.kt @@ -34,6 +34,7 @@ import foundation.e.apps.data.install.AppInstallDAO import foundation.e.apps.data.install.pkg.AppLoungePackageManager import foundation.e.apps.data.install.pkg.PkgManagerBR import foundation.e.apps.data.install.updates.UpdatesWorkManager +import foundation.e.apps.data.install.workmanager.InstallOrchestrator import foundation.e.apps.data.install.workmanager.InstallWorkManager import foundation.e.apps.data.preference.AppLoungeDataStore import foundation.e.apps.data.preference.AppLoungePreference @@ -77,6 +78,9 @@ class AppLoungeApplication : Application(), Configuration.Provider { @IoCoroutineScope lateinit var coroutineScope: CoroutineScope + @Inject + lateinit var installOrchestrator: InstallOrchestrator + @RequiresApi(Build.VERSION_CODES.TIRAMISU) override fun onCreate() { super.onCreate() @@ -122,6 +126,8 @@ class AppLoungeApplication : Application(), Configuration.Provider { ) removeStalledInstallationFromDb() + + installOrchestrator.init() } private fun removeStalledInstallationFromDb() = coroutineScope.launch { diff --git a/app/src/main/java/foundation/e/apps/data/install/AppInstallDAO.kt b/app/src/main/java/foundation/e/apps/data/install/AppInstallDAO.kt index a435d7428d0ae6c4e9c2d876e1f9465bf22091f9..397f993cfcc5a8249f60d242530daa16bd7031b1 100644 --- a/app/src/main/java/foundation/e/apps/data/install/AppInstallDAO.kt +++ b/app/src/main/java/foundation/e/apps/data/install/AppInstallDAO.kt @@ -8,6 +8,7 @@ import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.Update import foundation.e.apps.data.install.models.AppInstall +import kotlinx.coroutines.flow.Flow @Dao interface AppInstallDAO { @@ -18,6 +19,9 @@ interface AppInstallDAO { @Query("SELECT * FROM fuseddownload") fun getDownloadLiveList(): LiveData> + @Query("SELECT * FROM fuseddownload") + fun getDownloads(): Flow> + @Query("SELECT * FROM fuseddownload") suspend fun getDownloadList(): List diff --git a/app/src/main/java/foundation/e/apps/data/install/updates/UpdatesWorkManager.kt b/app/src/main/java/foundation/e/apps/data/install/updates/UpdatesWorkManager.kt index 348e1a99c85ababf1283a3366ee3cb10da647c7e..3031228d6ca950dd527a95dd6750ffc51a9c8c42 100644 --- a/app/src/main/java/foundation/e/apps/data/install/updates/UpdatesWorkManager.kt +++ b/app/src/main/java/foundation/e/apps/data/install/updates/UpdatesWorkManager.kt @@ -24,6 +24,7 @@ import androidx.work.ExistingPeriodicWorkPolicy import androidx.work.ExistingWorkPolicy import androidx.work.NetworkType import androidx.work.OneTimeWorkRequest +import androidx.work.OutOfQuotaPolicy import androidx.work.PeriodicWorkRequest import androidx.work.WorkManager import timber.log.Timber @@ -45,7 +46,12 @@ object UpdatesWorkManager { private fun buildOneTimeWorkRequest(): OneTimeWorkRequest { return OneTimeWorkRequest.Builder(UpdatesWorker::class.java).apply { - setConstraints(buildWorkerConstraints()) + setConstraints( + Constraints.Builder().apply { + setRequiredNetworkType(NetworkType.CONNECTED) + }.build() + ) + setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) addTag(USER_TAG) }.setInputData( Data.Builder() diff --git a/app/src/main/java/foundation/e/apps/data/install/workmanager/AppInstallProcessor.kt b/app/src/main/java/foundation/e/apps/data/install/workmanager/AppInstallProcessor.kt index 0e968c35b212aca9e5324e9fae8c596ee457997a..4079a8489a0bd2179e26e3ea772efe8ab9ea2295 100644 --- a/app/src/main/java/foundation/e/apps/data/install/workmanager/AppInstallProcessor.kt +++ b/app/src/main/java/foundation/e/apps/data/install/workmanager/AppInstallProcessor.kt @@ -129,6 +129,8 @@ class AppInstallProcessor @Inject constructor( isAnUpdate: Boolean = false, isSystemApp: Boolean = false ): Boolean { + val uniqueWorkName = InstallWorkManager.getUniqueWorkName(appInstall.packageName) + return try { val user = appLoungeDataStore.getUser() if (!isSystemApp && (user == User.GOOGLE || user == User.ANONYMOUS)) { @@ -141,13 +143,15 @@ class AppInstallProcessor @Inject constructor( if (!canEnqueue(appInstall)) return false appInstallComponents.appManagerWrapper.updateAwaiting(appInstall) - InstallWorkManager.enqueueWork(appInstall, isAnUpdate) + + // Use only for update work for now. For installation work, see InstallHelper#observeDownloads() + if (isAnUpdate) { + InstallWorkManager.enqueueWork(appInstall, true) + Timber.d("UPDATE: Successfully enqueued unique work: $uniqueWorkName") + } true } catch (e: Exception) { - Timber.e( - e, - "Enqueuing App install work is failed for ${appInstall.packageName} exception: ${e.localizedMessage}" - ) + Timber.e(e, "UPDATE: Failed to enqueue unique work for ${appInstall.packageName}") appInstallComponents.appManagerWrapper.installationIssue(appInstall) false } diff --git a/app/src/main/java/foundation/e/apps/data/install/workmanager/InstallOrchestrator.kt b/app/src/main/java/foundation/e/apps/data/install/workmanager/InstallOrchestrator.kt new file mode 100644 index 0000000000000000000000000000000000000000..62ed72ebfd62e52b01ec98260060c87a182d2cd2 --- /dev/null +++ b/app/src/main/java/foundation/e/apps/data/install/workmanager/InstallOrchestrator.kt @@ -0,0 +1,159 @@ +/* + * Copyright (C) 2026 e Foundation + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package foundation.e.apps.data.install.workmanager + +import android.content.Context +import androidx.work.WorkInfo +import androidx.work.WorkManager +import androidx.work.await +import dagger.hilt.android.qualifiers.ApplicationContext +import foundation.e.apps.data.di.qualifiers.IoCoroutineScope +import foundation.e.apps.data.enums.Status +import foundation.e.apps.data.install.AppInstallDAO +import foundation.e.apps.data.install.AppManagerWrapper +import foundation.e.apps.data.install.models.AppInstall +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + +class InstallOrchestrator @Inject constructor( + @param:ApplicationContext val context: Context, + @param:IoCoroutineScope private val scope: CoroutineScope, + private val appManagerWrapper: AppManagerWrapper, + private val installDao: AppInstallDAO +) { + + fun init() { + scope.launch { + runCatching { + cancelFailedDownloads() + }.onFailure { throwable -> + when (throwable) { + is CancellationException -> throw throwable + is Exception -> Timber.e(throwable, "Failed to reconcile startup downloads") + else -> throw throwable + } + } + observeDownloads() + } + } + + private fun observeDownloads() { + installDao.getDownloads().onEach { list -> + runCatching { + if (list.none { it.status == Status.DOWNLOADING || it.status == Status.INSTALLING }) { + list.find { it.status == Status.AWAITING } + ?.let { queuedDownload -> trigger(queuedDownload) } + } + }.onFailure { throwable -> + when (throwable) { + is CancellationException -> throw throwable + is Exception -> Timber.e(throwable, "Failed to enqueue download worker") + else -> throw throwable + } + } + }.launchIn(scope) + } + + private suspend fun trigger(download: AppInstall) { + val uniqueWorkName = InstallWorkManager.getUniqueWorkName(download.packageName) + + runCatching { + val operation = InstallWorkManager.enqueueWork(download, false) + operation.await() + Timber.d("INSTALL: Successfully enqueued unique work for ${download.name}: $uniqueWorkName") + }.onFailure { throwable -> + when (throwable) { + is CancellationException -> throw throwable + is Exception -> { + Timber.e( + throwable, + "INSTALL: Failed to enqueue unique work for ${download.name}: $uniqueWorkName" + ) + appManagerWrapper.installationIssue(download) + } + + else -> throw throwable + } + } + } + + private suspend fun cancelFailedDownloads() { + val workManager = WorkManager.getInstance(context) + val apps = installDao.getDownloads().firstOrNull().orEmpty() + + val activeWorkStates = setOf( + WorkInfo.State.ENQUEUED, + WorkInfo.State.RUNNING, + WorkInfo.State.BLOCKED + ) + + apps.filter { app -> + app.status in listOf( + Status.DOWNLOADING, + Status.DOWNLOADED, + Status.INSTALLING + ) + }.forEach { app -> + runCatching { + val workInfos = workManager.getWorkInfosByTagFlow(app.id).firstOrNull().orEmpty() + val hasActiveWork = workInfos.any { info -> info.state in activeWorkStates } + val hasActiveSession = + app.status == Status.INSTALLING && hasActiveInstallSession(app.packageName) + + when { + hasActiveWork || hasActiveSession -> return@forEach + appManagerWrapper.isFusedDownloadInstalled(app) -> { + appManagerWrapper.updateDownloadStatus(app, Status.INSTALLED) + } + + else -> { + Timber.i( + "INSTALL: Marking stale download as issue for ${app.name}/${app.packageName}" + ) + appManagerWrapper.installationIssue(app) + } + } + }.onFailure { throwable -> + when (throwable) { + is CancellationException -> throw throwable + is Exception -> { + Timber.e( + throwable, + "INSTALL: Failed to reconcile startup state for ${app.name}/${app.packageName}" + ) + } + + else -> throw throwable + } + } + } + } + + private fun hasActiveInstallSession(packageName: String): Boolean { + return context.packageManager.packageInstaller.allSessions.any { session -> + session.appPackageName == packageName && session.isActive + } + } +} diff --git a/app/src/main/java/foundation/e/apps/data/install/workmanager/InstallWorkManager.kt b/app/src/main/java/foundation/e/apps/data/install/workmanager/InstallWorkManager.kt index e3a453b6e4c6acadca1461c10c0995e471791c81..bbb41f1c73d1cad9cfaff049ed7baa7a7ee287d5 100644 --- a/app/src/main/java/foundation/e/apps/data/install/workmanager/InstallWorkManager.kt +++ b/app/src/main/java/foundation/e/apps/data/install/workmanager/InstallWorkManager.kt @@ -1,30 +1,48 @@ package foundation.e.apps.data.install.workmanager import android.app.Application +import androidx.work.Constraints import androidx.work.Data import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.Operation +import androidx.work.OutOfQuotaPolicy import androidx.work.WorkManager import foundation.e.apps.data.install.models.AppInstall import timber.log.Timber -import java.lang.Exception object InstallWorkManager { const val INSTALL_WORK_NAME = "APP_LOUNGE_INSTALL_APP" lateinit var context: Application - fun enqueueWork(appInstall: AppInstall, isUpdateWork: Boolean = false) { - WorkManager.getInstance(context).enqueueUniqueWork( - INSTALL_WORK_NAME, - ExistingWorkPolicy.APPEND_OR_REPLACE, - OneTimeWorkRequestBuilder().setInputData( - Data.Builder() - .putString(InstallAppWorker.INPUT_DATA_FUSED_DOWNLOAD, appInstall.id) - .putBoolean(InstallAppWorker.IS_UPDATE_WORK, isUpdateWork) - .build() - ).addTag(appInstall.id) - .build() - ) + fun enqueueWork(appInstall: AppInstall, isUpdateWork: Boolean = false): Operation { + val inputData = Data.Builder() + .putString(InstallAppWorker.INPUT_DATA_FUSED_DOWNLOAD, appInstall.id) + .putBoolean(InstallAppWorker.IS_UPDATE_WORK, isUpdateWork) + .build() + + val constraints = Constraints.Builder() + .setRequiresStorageNotLow(true) + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + + val request = OneTimeWorkRequestBuilder() + .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) + .setInputData(inputData) + .addTag(appInstall.id) + .addTag(INSTALL_WORK_NAME) + .setConstraints(constraints) + .build() + + val operation = WorkManager.getInstance(context) + .enqueueUniqueWork( + getUniqueWorkName(appInstall.packageName), + ExistingWorkPolicy.KEEP, + request + ) + + return operation } fun cancelWork(tag: String) { @@ -44,4 +62,6 @@ object InstallWorkManager { } return false } + + fun getUniqueWorkName(appPackageName: String): String = "${INSTALL_WORK_NAME}/$appPackageName" } diff --git a/app/src/main/java/foundation/e/apps/ui/updates/UpdatesFragment.kt b/app/src/main/java/foundation/e/apps/ui/updates/UpdatesFragment.kt index fb22fe8573826e9d06a6b13f66554ff7b5571481..40956790a686f59aced279662bb5ecf18c321b03 100644 --- a/app/src/main/java/foundation/e/apps/ui/updates/UpdatesFragment.kt +++ b/app/src/main/java/foundation/e/apps/ui/updates/UpdatesFragment.kt @@ -200,16 +200,35 @@ class UpdatesFragment : TimeoutFragment(R.layout.fragment_updates), ApplicationI ) ).get() .none { it.state == WorkInfo.State.RUNNING } - val noInstallJobIsRunning = WorkManager.getInstance(requireContext()) - .getWorkInfosForUniqueWork(InstallWorkManager.INSTALL_WORK_NAME) - .get() - .none { listOf(WorkInfo.State.ENQUEUED, WorkInfo.State.RUNNING).contains(it.state) } + + val noInstallJobIsRunning = !hasActiveInstallWork() Timber.d("no update jobs are running : $noUpdateJobIsRunning") Timber.d("no install jobs are running : $noInstallJobIsRunning") setButtonEnabled(noUpdateJobIsRunning && noInstallJobIsRunning) } + private fun hasActiveInstallWork(): Boolean { + val workManager = WorkManager.getInstance(requireContext()) + val isActive: (WorkInfo) -> Boolean = { info -> + info.state == WorkInfo.State.ENQUEUED || info.state == WorkInfo.State.RUNNING + } + + // tag-based, per-package unique work names + val hasActiveTaggedInstall = workManager + .getWorkInfosByTag(InstallWorkManager.INSTALL_WORK_NAME) + .get() + .any(isActive) + + // single unique work name used by older app versions + val hasActiveLegacyInstall = workManager + .getWorkInfosForUniqueWork(InstallWorkManager.INSTALL_WORK_NAME) + .get() + .any(isActive) + + return hasActiveTaggedInstall || hasActiveLegacyInstall + } + private fun handleUpdateEvent(appEvent: AppEvent) { val event = appEvent.data as ResultSupreme.WorkError<*> when (event.data) { diff --git a/app/src/test/java/foundation/e/apps/data/install/updates/UpdatesWorkManagerTest.kt b/app/src/test/java/foundation/e/apps/data/install/updates/UpdatesWorkManagerTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..77d6f32e0cdf4cfb59c43c6875b19d085d3a0b13 --- /dev/null +++ b/app/src/test/java/foundation/e/apps/data/install/updates/UpdatesWorkManagerTest.kt @@ -0,0 +1,134 @@ +/* + * Copyright (C) 2026 e Foundation + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package foundation.e.apps.data.install.updates + +import android.app.Application +import android.os.Build +import androidx.test.core.app.ApplicationProvider +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.NetworkType +import androidx.work.OutOfQuotaPolicy +import androidx.work.WorkInfo +import androidx.work.WorkManager +import androidx.work.impl.WorkManagerImpl +import androidx.work.impl.model.WorkSpec +import androidx.work.testing.WorkManagerTestInitHelper +import com.google.common.truth.Truth.assertThat +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.util.UUID +import java.util.concurrent.TimeUnit + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.N]) +class UpdatesWorkManagerTest { + + private lateinit var context: Application + private lateinit var workManager: WorkManager + + @Before + fun setup() { + context = ApplicationProvider.getApplicationContext() + WorkManagerTestInitHelper.initializeTestWorkManager(context) + workManager = WorkManager.getInstance(context) + workManager.cancelAllWork().result.get() + } + + @After + fun teardown() { + workManager.cancelAllWork().result.get() + } + + @Test + fun startUpdateAllWork_buildsExpectedOneTimeRequest() { + UpdatesWorkManager.startUpdateAllWork(context) + + val workInfo = getActiveUniqueWorkInfo("updates_work_user") + val workSpec = getWorkSpec(workInfo.id) + + assertThat(workInfo.tags).contains(UpdatesWorkManager.USER_TAG) + assertThat(workSpec.input.getBoolean(UpdatesWorker.IS_AUTO_UPDATE, true)).isFalse() + assertThat(workSpec.constraints.requiredNetworkType).isEqualTo(NetworkType.CONNECTED) + assertThat(workSpec.expedited).isTrue() + assertThat(workSpec.outOfQuotaPolicy) + .isEqualTo(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) + } + + @Test + fun startUpdateAllWork_replacesExistingUniqueWork() { + UpdatesWorkManager.startUpdateAllWork(context) + val firstWorkId = getActiveUniqueWorkInfo("updates_work_user").id + + UpdatesWorkManager.startUpdateAllWork(context) + + val allWorkInfos = workManager.getWorkInfosForUniqueWork("updates_work_user").get() + val activeWorkInfos = allWorkInfos.filter { !it.state.isFinished } + + assertThat(activeWorkInfos).hasSize(1) + assertThat(activeWorkInfos.single().id).isNotEqualTo(firstWorkId) + } + + @Test + fun enqueueWork_buildsExpectedPeriodicRequest() { + UpdatesWorkManager.enqueueWork(context, interval = 6, ExistingPeriodicWorkPolicy.REPLACE) + + val workInfo = getActiveUniqueWorkInfo("updates_work") + val workSpec = getWorkSpec(workInfo.id) + + assertThat(workInfo.tags).contains(UpdatesWorkManager.TAG) + assertThat(workSpec.intervalDuration).isEqualTo(TimeUnit.HOURS.toMillis(6)) + assertThat(workSpec.constraints.requiresBatteryNotLow()).isTrue() + assertThat(workSpec.constraints.requiredNetworkType).isEqualTo(NetworkType.CONNECTED) + } + + @Test + fun enqueueWork_respectsExistingPeriodicWorkPolicy() { + UpdatesWorkManager.enqueueWork(context, interval = 6, ExistingPeriodicWorkPolicy.KEEP) + val initialWorkInfo = getActiveUniqueWorkInfo("updates_work") + val initialWorkSpec = getWorkSpec(initialWorkInfo.id) + + UpdatesWorkManager.enqueueWork(context, interval = 12, ExistingPeriodicWorkPolicy.KEEP) + + val keptWorkInfo = getActiveUniqueWorkInfo("updates_work") + val keptWorkSpec = getWorkSpec(keptWorkInfo.id) + assertThat(keptWorkInfo.id).isEqualTo(initialWorkInfo.id) + assertThat(keptWorkSpec.intervalDuration).isEqualTo(initialWorkSpec.intervalDuration) + + UpdatesWorkManager.enqueueWork(context, interval = 24, ExistingPeriodicWorkPolicy.REPLACE) + + val replacedWorkInfo = getActiveUniqueWorkInfo("updates_work") + val replacedWorkSpec = getWorkSpec(replacedWorkInfo.id) + assertThat(replacedWorkInfo.id).isNotEqualTo(initialWorkInfo.id) + assertThat(replacedWorkSpec.intervalDuration).isEqualTo(TimeUnit.HOURS.toMillis(24)) + } + + private fun getActiveUniqueWorkInfo(uniqueWorkName: String): WorkInfo { + return workManager.getWorkInfosForUniqueWork(uniqueWorkName).get() + .single { !it.state.isFinished } + } + + private fun getWorkSpec(workId: UUID): WorkSpec { + val workManagerImpl = WorkManagerImpl.getInstance(context) + return requireNotNull(workManagerImpl.workDatabase.workSpecDao().getWorkSpec(workId.toString())) + } +} diff --git a/app/src/test/java/foundation/e/apps/data/install/workmanager/InstallWorkManagerTest.kt b/app/src/test/java/foundation/e/apps/data/install/workmanager/InstallWorkManagerTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..a3746a4ef42905ff7dd09037dc82ba39d8330cc8 --- /dev/null +++ b/app/src/test/java/foundation/e/apps/data/install/workmanager/InstallWorkManagerTest.kt @@ -0,0 +1,187 @@ +/* + * Copyright (C) 2026 e Foundation + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package foundation.e.apps.data.install.workmanager + +import android.app.Application +import android.content.Context +import android.os.Build +import androidx.test.core.app.ApplicationProvider +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkInfo +import androidx.work.WorkManager +import androidx.work.Worker +import androidx.work.WorkerParameters +import androidx.work.impl.WorkManagerImpl +import androidx.work.impl.model.WorkSpec +import androidx.work.testing.WorkManagerTestInitHelper +import com.google.common.truth.Truth.assertThat +import foundation.e.apps.data.install.models.AppInstall +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.util.UUID +import java.util.concurrent.TimeUnit + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.N]) +class InstallWorkManagerTest { + + private lateinit var context: Application + private lateinit var workManager: WorkManager + + @Before + fun setup() { + context = ApplicationProvider.getApplicationContext() + WorkManagerTestInitHelper.initializeTestWorkManager(context) + InstallWorkManager.context = context + workManager = WorkManager.getInstance(context) + workManager.cancelAllWork().result.get() + } + + @After + fun teardown() { + workManager.cancelAllWork().result.get() + Thread.interrupted() + } + + @Test + fun enqueueWork_buildsExpectedRequestForInstall() { + val appInstall = AppInstall(id = "id.install", packageName = "foundation.e.install") + + InstallWorkManager.enqueueWork(appInstall).result.get() + + val uniqueWorkName = InstallWorkManager.getUniqueWorkName(appInstall.packageName) + val workInfo = workManager.getWorkInfosForUniqueWork(uniqueWorkName).get().single() + val workSpec = getWorkSpec(workInfo.id) + + assertThat(workInfo.tags).containsAtLeast(appInstall.id, InstallWorkManager.INSTALL_WORK_NAME) + assertThat(workSpec.input.getString(InstallAppWorker.INPUT_DATA_FUSED_DOWNLOAD)) + .isEqualTo(appInstall.id) + assertThat(workSpec.input.getBoolean(InstallAppWorker.IS_UPDATE_WORK, true)).isFalse() + assertThat(workSpec.constraints.requiredNetworkType).isEqualTo(NetworkType.CONNECTED) + assertThat(workSpec.constraints.requiresStorageNotLow()).isTrue() + } + + @Test + fun enqueueWork_setsUpdateFlagWhenRequested() { + val appInstall = AppInstall(id = "id.update", packageName = "foundation.e.update") + + InstallWorkManager.enqueueWork(appInstall, isUpdateWork = true).result.get() + + val uniqueWorkName = InstallWorkManager.getUniqueWorkName(appInstall.packageName) + val workInfo = workManager.getWorkInfosForUniqueWork(uniqueWorkName).get().single() + val workSpec = getWorkSpec(workInfo.id) + + assertThat(workSpec.input.getBoolean(InstallAppWorker.IS_UPDATE_WORK, false)).isTrue() + } + + @Test + fun cancelWork_cancelsAllWorkByTag() { + val tag = "id.cancel" + val request = OneTimeWorkRequestBuilder() + .setInitialDelay(1, TimeUnit.HOURS) + .addTag(tag) + .build() + + workManager.enqueue(request).result.get() + + InstallWorkManager.cancelWork(tag) + + val state = waitUntilFinished(request.id) + assertThat(state).isEqualTo(WorkInfo.State.CANCELLED) + } + + @Test + fun checkWorkIsAlreadyAvailable_returnsTrueWhenActiveWorkExists() { + val tag = "id.active" + val request = OneTimeWorkRequestBuilder() + .setInitialDelay(1, TimeUnit.HOURS) + .addTag(tag) + .build() + + workManager.enqueue(request).result.get() + + assertThat(InstallWorkManager.checkWorkIsAlreadyAvailable(tag)).isTrue() + } + + @Test + fun checkWorkIsAlreadyAvailable_returnsFalseWhenOnlyFinishedWorkExists() { + val tag = "id.finished" + val request = OneTimeWorkRequestBuilder() + .setInitialDelay(1, TimeUnit.HOURS) + .addTag(tag) + .build() + + workManager.enqueue(request).result.get() + InstallWorkManager.cancelWork(tag) + waitUntilFinished(request.id) + + assertThat(InstallWorkManager.checkWorkIsAlreadyAvailable(tag)).isFalse() + } + + @Test + fun checkWorkIsAlreadyAvailable_returnsFalseWhenNoWorkExistsForTag() { + assertThat(InstallWorkManager.checkWorkIsAlreadyAvailable("id.missing")).isFalse() + } + + @Test + fun checkWorkIsAlreadyAvailable_returnsFalseWhenThreadIsInterrupted() { + Thread.currentThread().interrupt() + + val result = InstallWorkManager.checkWorkIsAlreadyAvailable("id.interrupted") + + assertThat(result).isFalse() + assertThat(Thread.currentThread().isInterrupted).isFalse() + } + + @Test + fun getUniqueWorkName_returnsExpectedValueForNormalAndEmptyPackage() { + assertThat(InstallWorkManager.getUniqueWorkName("foundation.e.apps")) + .isEqualTo("${InstallWorkManager.INSTALL_WORK_NAME}/foundation.e.apps") + assertThat(InstallWorkManager.getUniqueWorkName("")) + .isEqualTo("${InstallWorkManager.INSTALL_WORK_NAME}/") + } + + private fun getWorkSpec(workId: UUID): WorkSpec { + val workManagerImpl = WorkManagerImpl.getInstance(context) + return requireNotNull(workManagerImpl.workDatabase.workSpecDao().getWorkSpec(workId.toString())) + } + + private fun waitUntilFinished(workId: UUID): WorkInfo.State { + repeat(30) { + val state = requireNotNull(workManager.getWorkInfoById(workId).get()).state + if (state.isFinished) { + return state + } + Thread.sleep(50) + } + return requireNotNull(workManager.getWorkInfoById(workId).get()).state + } + + class NoOpWorker( + appContext: Context, + workerParams: WorkerParameters + ) : Worker(appContext, workerParams) { + override fun doWork(): Result = Result.success() + } +} diff --git a/app/src/test/java/foundation/e/apps/install/workmanager/InstallOrchestratorTest.kt b/app/src/test/java/foundation/e/apps/install/workmanager/InstallOrchestratorTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..9a11fd42bf89aa188f68bd86e86aec707e0e5eaa --- /dev/null +++ b/app/src/test/java/foundation/e/apps/install/workmanager/InstallOrchestratorTest.kt @@ -0,0 +1,322 @@ +/* + * Copyright (C) 2026 e Foundation + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package foundation.e.apps.install.workmanager + +import android.content.Context +import android.content.ContextWrapper +import android.content.pm.PackageInstaller +import android.content.pm.PackageManager +import android.os.Build +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.Operation +import androidx.work.Worker +import androidx.work.WorkerParameters +import androidx.work.WorkManager +import androidx.work.testing.WorkManagerTestInitHelper +import androidx.test.core.app.ApplicationProvider +import com.google.common.util.concurrent.Futures +import foundation.e.apps.data.enums.Status +import foundation.e.apps.data.install.AppInstallDAO +import foundation.e.apps.data.install.AppManagerWrapper +import foundation.e.apps.data.install.models.AppInstall +import foundation.e.apps.data.install.workmanager.InstallOrchestrator +import foundation.e.apps.data.install.workmanager.InstallWorkManager +import foundation.e.apps.util.MainCoroutineRule +import io.mockk.every +import io.mockk.mockkObject +import io.mockk.unmockkObject +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.times +import org.mockito.kotlin.verify as verifyMockito +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.util.concurrent.TimeUnit + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.N]) +class InstallOrchestratorTest { + + @get:Rule + val mainCoroutineRule = MainCoroutineRule() + + private val context: Context = ApplicationProvider.getApplicationContext() + private val installDao = mock() + private val appManagerWrapper = mock() + + private var isInstallWorkManagerMocked = false + + @Before + fun setup() { + WorkManagerTestInitHelper.initializeTestWorkManager(context) + } + + @After + fun teardown() { + if (isInstallWorkManagerMocked) { + unmockkObject(InstallWorkManager) + isInstallWorkManagerMocked = false + } + } + + @Test + fun init_marksStaleDownloadAsInstallationIssue() = runTest { + val app = createAppInstall(status = Status.DOWNLOADING) + + whenever(installDao.getDownloads()).thenReturn(flowOf(listOf(app)), emptyFlow()) + whenever(appManagerWrapper.isFusedDownloadInstalled(app)).thenReturn(false) + + val installOrchestrator = InstallOrchestrator(context, this, appManagerWrapper, installDao) + + installOrchestrator.init() + advanceUntilIdle() + + verifyMockito(appManagerWrapper).installationIssue(app) + verifyMockito(appManagerWrapper, never()).updateDownloadStatus(any(), any()) + } + + @Test + fun init_updatesStatusToInstalledWhenAppAlreadyInstalled() = runTest { + val app = createAppInstall(status = Status.DOWNLOADED) + + whenever(installDao.getDownloads()).thenReturn(flowOf(listOf(app)), emptyFlow()) + whenever(appManagerWrapper.isFusedDownloadInstalled(app)).thenReturn(true) + + val installOrchestrator = InstallOrchestrator(context, this, appManagerWrapper, installDao) + + installOrchestrator.init() + advanceUntilIdle() + + verifyMockito(appManagerWrapper).updateDownloadStatus(app, Status.INSTALLED) + verifyMockito(appManagerWrapper, never()).installationIssue(any()) + } + + @Test + fun init_skipsReconciliationWhenInstallSessionIsActive() = runTest { + val app = createAppInstall(status = Status.INSTALLING) + val packageManager = mock() + val packageInstaller = mock() + val sessionInfo = mock() + val wrappedContext = object : ContextWrapper(context) { + override fun getPackageManager(): PackageManager = packageManager + } + + whenever(sessionInfo.appPackageName).thenReturn(app.packageName) + whenever(sessionInfo.isActive).thenReturn(true) + whenever(packageManager.packageInstaller).thenReturn(packageInstaller) + whenever(packageInstaller.allSessions).thenReturn(listOf(sessionInfo)) + whenever(installDao.getDownloads()).thenReturn(flowOf(listOf(app)), emptyFlow()) + + val installOrchestrator = InstallOrchestrator(wrappedContext, this, appManagerWrapper, installDao) + + installOrchestrator.init() + advanceUntilIdle() + + verifyMockito(appManagerWrapper, never()).installationIssue(any()) + verifyMockito(appManagerWrapper, never()).updateDownloadStatus(any(), any()) + } + + @Test + fun init_triggersAwaitingDownloadWhenNoActiveDownloadOrInstall() = runTest { + val awaiting = createAppInstall(id = "app.awaiting", status = Status.AWAITING) + mockInstallWorkManagerSuccess() + + whenever(installDao.getDownloads()).thenReturn(flowOf(emptyList()), flowOf(listOf(awaiting))) + + val installOrchestrator = InstallOrchestrator(context, this, appManagerWrapper, installDao) + + installOrchestrator.init() + advanceUntilIdle() + + verify(exactly = 1) { InstallWorkManager.enqueueWork(awaiting, false) } + } + + @Test + fun init_doesNotTriggerAwaitingWhenThereIsAnActiveDownload() = runTest { + val active = createAppInstall(id = "app.active", status = Status.DOWNLOADING) + val awaiting = createAppInstall(id = "app.awaiting", status = Status.AWAITING) + mockInstallWorkManagerSuccess() + + whenever(installDao.getDownloads()) + .thenReturn(flowOf(emptyList()), flowOf(listOf(active, awaiting))) + + val installOrchestrator = InstallOrchestrator(context, this, appManagerWrapper, installDao) + + installOrchestrator.init() + advanceUntilIdle() + + verify(exactly = 0) { InstallWorkManager.enqueueWork(any(), any()) } + } + + @Test + fun init_reportsInstallationIssueWhenEnqueueFails() = runTest { + val awaiting = createAppInstall(id = "app.awaiting", status = Status.AWAITING) + mockInstallWorkManagerFailure() + + whenever(installDao.getDownloads()).thenReturn(flowOf(emptyList()), flowOf(listOf(awaiting))) + + val installOrchestrator = InstallOrchestrator(context, this, appManagerWrapper, installDao) + + installOrchestrator.init() + advanceUntilIdle() + + verifyMockito(appManagerWrapper, times(1)).installationIssue(eq(awaiting)) + } + + @Test + fun init_doesNotReportIssueWhenEnqueueIsCancelled() = runTest { + val awaiting = createAppInstall(id = "app.awaiting.cancelled", status = Status.AWAITING) + mockInstallWorkManagerCancellation() + + whenever(installDao.getDownloads()).thenReturn(flowOf(emptyList()), flowOf(listOf(awaiting))) + + val installOrchestrator = InstallOrchestrator(context, this, appManagerWrapper, installDao) + + installOrchestrator.init() + advanceUntilIdle() + + verifyMockito(appManagerWrapper, never()).installationIssue(any()) + } + + @Test + fun init_skipsReconciliationWhenActiveWorkExists() = runTest { + val app = createAppInstall(id = "app.active.work", status = Status.DOWNLOADING) + val request = OneTimeWorkRequestBuilder() + .setInitialDelay(1, TimeUnit.HOURS) + .addTag(app.id) + .build() + WorkManager.getInstance(context).enqueue(request).result.get() + + whenever(installDao.getDownloads()).thenReturn(flowOf(listOf(app)), emptyFlow()) + + val installOrchestrator = InstallOrchestrator(context, this, appManagerWrapper, installDao) + + installOrchestrator.init() + advanceUntilIdle() + + verifyMockito(appManagerWrapper, never()).installationIssue(any()) + verifyMockito(appManagerWrapper, never()).updateDownloadStatus(any(), any()) + verifyMockito(appManagerWrapper, never()).isFusedDownloadInstalled(any()) + } + + @Test + fun init_doesNotReportIssueWhenEnqueueSucceeds() = runTest { + val awaiting = createAppInstall(id = "app.awaiting.success", status = Status.AWAITING) + mockInstallWorkManagerSuccess() + + whenever(installDao.getDownloads()).thenReturn(flowOf(emptyList()), flowOf(listOf(awaiting))) + + val installOrchestrator = InstallOrchestrator(context, this, appManagerWrapper, installDao) + + installOrchestrator.init() + advanceUntilIdle() + + verifyMockito(appManagerWrapper, never()).installationIssue(any()) + } + + @Test + fun init_continuesToObserveWhenReconciliationThrowsException() = runTest { + val awaiting = createAppInstall(id = "app.awaiting.after.exception", status = Status.AWAITING) + mockInstallWorkManagerSuccess() + + whenever(installDao.getDownloads()) + .thenThrow(RuntimeException("reconcile failed")) + .thenReturn(flowOf(listOf(awaiting))) + + val installOrchestrator = InstallOrchestrator(context, this, appManagerWrapper, installDao) + + installOrchestrator.init() + advanceUntilIdle() + + verify(exactly = 1) { InstallWorkManager.enqueueWork(awaiting, false) } + } + + @Test + fun init_stopsAfterCancellationExceptionDuringReconciliation() = runTest { + whenever(installDao.getDownloads()).thenThrow(CancellationException("cancel reconcile")) + + val installOrchestrator = InstallOrchestrator(context, this, appManagerWrapper, installDao) + + installOrchestrator.init() + advanceUntilIdle() + + verifyMockito(installDao, times(1)).getDownloads() + } + + private fun mockInstallWorkManagerSuccess() { + mockkObject(InstallWorkManager) + isInstallWorkManagerMocked = true + every { InstallWorkManager.getUniqueWorkName(any()) } answers { callOriginal() } + every { InstallWorkManager.enqueueWork(any(), any()) } returns successfulOperation() + } + + private fun mockInstallWorkManagerFailure() { + mockkObject(InstallWorkManager) + isInstallWorkManagerMocked = true + every { InstallWorkManager.getUniqueWorkName(any()) } answers { callOriginal() } + every { InstallWorkManager.enqueueWork(any(), any()) } throws RuntimeException("enqueue failed") + } + + private fun mockInstallWorkManagerCancellation() { + mockkObject(InstallWorkManager) + isInstallWorkManagerMocked = true + every { InstallWorkManager.getUniqueWorkName(any()) } answers { callOriginal() } + every { InstallWorkManager.enqueueWork(any(), any()) } throws CancellationException("enqueue cancelled") + } + + private fun successfulOperation(): Operation { + val operation = mock() + whenever(operation.result).thenReturn(Futures.immediateFuture(Operation.SUCCESS)) + return operation + } + + private fun createAppInstall( + id: String = "app.id", + packageName: String = "foundation.e.app", + status: Status, + ) = AppInstall( + id = id, + name = id, + packageName = packageName, + status = status + ) + + class NoOpWorker( + appContext: Context, + workerParams: WorkerParameters + ) : Worker(appContext, workerParams) { + override fun doWork(): Result = Result.success() + } +} 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 ade2dc588663aea237a76593de4bf64f3c89f43b..d34b58700639e4209935f812433bfd638b3cdeb1 100644 --- a/app/src/test/java/foundation/e/apps/installProcessor/AppInstallProcessorTest.kt +++ b/app/src/test/java/foundation/e/apps/installProcessor/AppInstallProcessorTest.kt @@ -23,8 +23,13 @@ import android.net.ConnectivityManager import android.net.Network import android.net.NetworkCapabilities import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.work.Operation +import com.google.common.util.concurrent.Futures import foundation.e.apps.data.ResultSupreme +import foundation.e.apps.data.application.data.Application +import foundation.e.apps.data.enums.Source import foundation.e.apps.data.enums.Status +import foundation.e.apps.data.enums.Type import foundation.e.apps.data.fdroid.FDroidRepository import foundation.e.apps.data.application.ApplicationRepository import foundation.e.apps.data.enums.ResultStatus @@ -38,10 +43,13 @@ import foundation.e.apps.domain.model.ContentRatingValidity import foundation.e.apps.data.install.AppInstallComponents import foundation.e.apps.data.install.notification.StorageNotificationManager import foundation.e.apps.data.install.workmanager.AppInstallProcessor +import foundation.e.apps.data.install.workmanager.InstallWorkManager import foundation.e.apps.data.system.StorageComputer import foundation.e.apps.util.MainCoroutineRule +import io.mockk.every import io.mockk.coEvery import io.mockk.coVerify +import io.mockk.verify import io.mockk.mockk import io.mockk.mockkObject import io.mockk.unmockkObject @@ -49,6 +57,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue +import org.junit.After import org.junit.Before import org.junit.Rule import org.junit.Test @@ -97,6 +106,8 @@ class AppInstallProcessorTest { @Mock private lateinit var storageNotificationManager: StorageNotificationManager + private var isInstallWorkManagerMocked = false + @Before fun setup() { MockitoAnnotations.openMocks(this) @@ -117,6 +128,14 @@ class AppInstallProcessorTest { ) } + @After + fun teardown() { + if (isInstallWorkManagerMocked) { + unmockkObject(InstallWorkManager) + isInstallWorkManagerMocked = false + } + } + @Test fun processInstallTest() = runTest { val fusedDownload = initTest() @@ -222,7 +241,7 @@ class AppInstallProcessorTest { @Test fun canEnqueue_returnsTrueWhenAllChecksPass() = runTest { val appInstall = AppInstall( - type = foundation.e.apps.data.enums.Type.PWA, + type = Type.PWA, id = "123", status = Status.AWAITING, downloadURLList = mutableListOf("apk"), @@ -238,7 +257,7 @@ class AppInstallProcessorTest { Mockito.`when`(validateAppAgeRatingUseCase(appInstall)) .thenReturn(ResultSupreme.create(ResultStatus.OK, ContentRatingValidity(true))) everyNetworkAvailable() - io.mockk.every { StorageComputer.spaceMissing(appInstall) } returns 0 + every { StorageComputer.spaceMissing(appInstall) } returns 0 val result = processor.canEnqueue(appInstall) @@ -252,7 +271,7 @@ class AppInstallProcessorTest { @Test fun canEnqueue_returnsFalseWhenNetworkUnavailable() = runTest { val appInstall = AppInstall( - type = foundation.e.apps.data.enums.Type.PWA, + type = Type.PWA, id = "123", status = Status.AWAITING, downloadURLList = mutableListOf("apk"), @@ -268,7 +287,7 @@ class AppInstallProcessorTest { Mockito.`when`(validateAppAgeRatingUseCase(appInstall)) .thenReturn(ResultSupreme.create(ResultStatus.OK, ContentRatingValidity(true))) everyNetworkUnavailable() - io.mockk.every { StorageComputer.spaceMissing(appInstall) } returns 0 + every { StorageComputer.spaceMissing(appInstall) } returns 0 val result = processor.canEnqueue(appInstall) @@ -282,7 +301,7 @@ class AppInstallProcessorTest { @Test fun canEnqueue_returnsFalseWhenStorageMissing() = runTest { val appInstall = AppInstall( - type = foundation.e.apps.data.enums.Type.PWA, + type = Type.PWA, id = "123", status = Status.AWAITING, downloadURLList = mutableListOf("apk"), @@ -298,7 +317,7 @@ class AppInstallProcessorTest { Mockito.`when`(validateAppAgeRatingUseCase(appInstall)) .thenReturn(ResultSupreme.create(ResultStatus.OK, ContentRatingValidity(true))) everyNetworkAvailable() - io.mockk.every { StorageComputer.spaceMissing(appInstall) } returns 100L + every { StorageComputer.spaceMissing(appInstall) } returns 100L val result = processor.canEnqueue(appInstall) @@ -313,7 +332,7 @@ class AppInstallProcessorTest { @Test fun canEnqueue_returnsFalseWhenAddDownloadFails() = runTest { val appInstall = AppInstall( - type = foundation.e.apps.data.enums.Type.PWA, + type = Type.PWA, id = "123", status = Status.AWAITING, downloadURLList = mutableListOf("apk"), @@ -329,7 +348,7 @@ class AppInstallProcessorTest { Mockito.`when`(validateAppAgeRatingUseCase(appInstall)) .thenReturn(ResultSupreme.create(ResultStatus.OK, ContentRatingValidity(true))) everyNetworkAvailable() - io.mockk.every { StorageComputer.spaceMissing(appInstall) } returns 0L + every { StorageComputer.spaceMissing(appInstall) } returns 0L val result = processor.canEnqueue(appInstall) @@ -343,7 +362,7 @@ class AppInstallProcessorTest { @Test fun canEnqueue_returnsFalseWhenAgeLimitInvalid() = runTest { val appInstall = AppInstall( - type = foundation.e.apps.data.enums.Type.PWA, + type = Type.PWA, id = "123", status = Status.AWAITING, downloadURLList = mutableListOf("apk"), @@ -359,7 +378,7 @@ class AppInstallProcessorTest { Mockito.`when`(validateAppAgeRatingUseCase(appInstall)) .thenReturn(ResultSupreme.create(ResultStatus.UNKNOWN, ContentRatingValidity(false))) everyNetworkAvailable() - io.mockk.every { StorageComputer.spaceMissing(appInstall) } returns 0L + every { StorageComputer.spaceMissing(appInstall) } returns 0L val result = processor.canEnqueue(appInstall) @@ -370,6 +389,210 @@ class AppInstallProcessorTest { } } + @Test + fun enqueueFusedDownload_returnsTrueAndEnqueuesWorkForUpdate() = runTest { + val appInstall = createEnqueueAppInstall() + val appManagerWrapper = mockk(relaxed = true) + val processor = createProcessorForCanEnqueue(appManagerWrapper) + + mockInstallWorkManagerSuccess() + mockkObject(StorageComputer) + try { + everyNetworkAvailable() + Mockito.`when`(validateAppAgeRatingUseCase(appInstall)) + .thenReturn(ResultSupreme.create(ResultStatus.OK, ContentRatingValidity(true))) + coEvery { appManagerWrapper.addDownload(appInstall) } returns true + every { StorageComputer.spaceMissing(appInstall) } returns 0L + + val result = processor.enqueueFusedDownload(appInstall, isAnUpdate = true, isSystemApp = true) + + assertTrue(result) + coVerify { appManagerWrapper.updateAwaiting(appInstall) } + verify(exactly = 1) { InstallWorkManager.enqueueWork(appInstall, true) } + } finally { + unmockkObject(StorageComputer) + } + } + + @Test + fun enqueueFusedDownload_returnsFalseAndMarksIssueWhenUpdateEnqueueFails() = runTest { + val appInstall = createEnqueueAppInstall() + val appManagerWrapper = mockk(relaxed = true) + val processor = createProcessorForCanEnqueue(appManagerWrapper) + + mockInstallWorkManagerFailure() + mockkObject(StorageComputer) + try { + everyNetworkAvailable() + Mockito.`when`(validateAppAgeRatingUseCase(appInstall)) + .thenReturn(ResultSupreme.create(ResultStatus.OK, ContentRatingValidity(true))) + coEvery { appManagerWrapper.addDownload(appInstall) } returns true + every { StorageComputer.spaceMissing(appInstall) } returns 0L + + val result = processor.enqueueFusedDownload(appInstall, isAnUpdate = true, isSystemApp = true) + + assertEquals(false, result) + coVerify { appManagerWrapper.updateAwaiting(appInstall) } + coVerify { appManagerWrapper.installationIssue(appInstall) } + } finally { + unmockkObject(StorageComputer) + } + } + + @Test + fun enqueueFusedDownload_returnsTrueWithoutEnqueueingWorkForRegularInstall() = runTest { + val appInstall = createEnqueueAppInstall() + val appManagerWrapper = mockk(relaxed = true) + val processor = createProcessorForCanEnqueue(appManagerWrapper) + + mockInstallWorkManagerSuccess() + mockkObject(StorageComputer) + try { + everyNetworkAvailable() + Mockito.`when`(validateAppAgeRatingUseCase(appInstall)) + .thenReturn(ResultSupreme.create(ResultStatus.OK, ContentRatingValidity(true))) + coEvery { appManagerWrapper.addDownload(appInstall) } returns true + every { StorageComputer.spaceMissing(appInstall) } returns 0L + + val result = processor.enqueueFusedDownload(appInstall, isAnUpdate = false, isSystemApp = true) + + assertTrue(result) + coVerify { appManagerWrapper.updateAwaiting(appInstall) } + verify(exactly = 0) { InstallWorkManager.enqueueWork(any(), any()) } + } finally { + unmockkObject(StorageComputer) + } + } + + @Test + fun enqueueFusedDownload_skipsWorkManagerFailurePathForRegularInstall() = runTest { + val appInstall = createEnqueueAppInstall() + val appManagerWrapper = mockk(relaxed = true) + val processor = createProcessorForCanEnqueue(appManagerWrapper) + + mockInstallWorkManagerFailure() + mockkObject(StorageComputer) + try { + everyNetworkAvailable() + Mockito.`when`(validateAppAgeRatingUseCase(appInstall)) + .thenReturn(ResultSupreme.create(ResultStatus.OK, ContentRatingValidity(true))) + coEvery { appManagerWrapper.addDownload(appInstall) } returns true + every { StorageComputer.spaceMissing(appInstall) } returns 0L + + val result = processor.enqueueFusedDownload(appInstall, isAnUpdate = false, isSystemApp = true) + + assertTrue(result) + verify(exactly = 0) { InstallWorkManager.enqueueWork(any(), any()) } + coVerify(exactly = 0) { appManagerWrapper.installationIssue(appInstall) } + } finally { + unmockkObject(StorageComputer) + } + } + + @Test + fun initAppInstall_enqueuesUpdateWorkWhenExplicitFlagIsTrue() = runTest { + val application = createApplication(status = Status.INSTALLED) + val appInstall = createExpectedAppInstall(application) + val appManagerWrapper = mockk(relaxed = true) + val processor = createProcessorForCanEnqueue(appManagerWrapper) + + mockInstallWorkManagerSuccess() + mockkObject(StorageComputer) + try { + everyNetworkAvailable() + Mockito.`when`(validateAppAgeRatingUseCase(appInstall)) + .thenReturn(ResultSupreme.create(ResultStatus.OK, ContentRatingValidity(true))) + every { appManagerWrapper.isFusedDownloadInstalled(appInstall) } returns false + coEvery { appManagerWrapper.addDownload(appInstall) } returns true + every { StorageComputer.spaceMissing(appInstall) } returns 0L + + val result = processor.initAppInstall(application, isAnUpdate = true) + + assertTrue(result) + verify(exactly = 1) { InstallWorkManager.enqueueWork(appInstall, true) } + } finally { + unmockkObject(StorageComputer) + } + } + + @Test + fun initAppInstall_enqueuesUpdateWorkWhenApplicationIsUpdatable() = runTest { + val application = createApplication(status = Status.UPDATABLE) + val appInstall = createExpectedAppInstall(application) + val appManagerWrapper = mockk(relaxed = true) + val processor = createProcessorForCanEnqueue(appManagerWrapper) + + mockInstallWorkManagerSuccess() + mockkObject(StorageComputer) + try { + everyNetworkAvailable() + Mockito.`when`(validateAppAgeRatingUseCase(appInstall)) + .thenReturn(ResultSupreme.create(ResultStatus.OK, ContentRatingValidity(true))) + every { appManagerWrapper.isFusedDownloadInstalled(appInstall) } returns false + coEvery { appManagerWrapper.addDownload(appInstall) } returns true + every { StorageComputer.spaceMissing(appInstall) } returns 0L + + val result = processor.initAppInstall(application, isAnUpdate = false) + + assertTrue(result) + verify(exactly = 1) { InstallWorkManager.enqueueWork(appInstall, true) } + } finally { + unmockkObject(StorageComputer) + } + } + + @Test + fun initAppInstall_enqueuesUpdateWorkWhenAppIsAlreadyInstalled() = runTest { + val application = createApplication(status = Status.INSTALLED) + val appInstall = createExpectedAppInstall(application) + val appManagerWrapper = mockk(relaxed = true) + val processor = createProcessorForCanEnqueue(appManagerWrapper) + + mockInstallWorkManagerSuccess() + mockkObject(StorageComputer) + try { + everyNetworkAvailable() + Mockito.`when`(validateAppAgeRatingUseCase(appInstall)) + .thenReturn(ResultSupreme.create(ResultStatus.OK, ContentRatingValidity(true))) + every { appManagerWrapper.isFusedDownloadInstalled(appInstall) } returns true + coEvery { appManagerWrapper.addDownload(appInstall) } returns true + every { StorageComputer.spaceMissing(appInstall) } returns 0L + + val result = processor.initAppInstall(application, isAnUpdate = false) + + assertTrue(result) + verify(exactly = 1) { InstallWorkManager.enqueueWork(appInstall, true) } + } finally { + unmockkObject(StorageComputer) + } + } + + @Test + fun initAppInstall_doesNotEnqueueWorkWhenInstallIsNotAnUpdate() = runTest { + val application = createApplication(status = Status.INSTALLED) + val appInstall = createExpectedAppInstall(application) + val appManagerWrapper = mockk(relaxed = true) + val processor = createProcessorForCanEnqueue(appManagerWrapper) + + mockInstallWorkManagerFailure() + mockkObject(StorageComputer) + try { + everyNetworkAvailable() + Mockito.`when`(validateAppAgeRatingUseCase(appInstall)) + .thenReturn(ResultSupreme.create(ResultStatus.OK, ContentRatingValidity(true))) + every { appManagerWrapper.isFusedDownloadInstalled(appInstall) } returns false + coEvery { appManagerWrapper.addDownload(appInstall) } returns true + every { StorageComputer.spaceMissing(appInstall) } returns 0L + + val result = processor.initAppInstall(application, isAnUpdate = false) + + assertTrue(result) + verify(exactly = 0) { InstallWorkManager.enqueueWork(any(), any()) } + } finally { + unmockkObject(StorageComputer) + } + } + private suspend fun runProcessInstall(appInstall: AppInstall): AppInstall? { appInstallProcessor.processInstall(appInstall.id, false) { // _ignored_ @@ -402,6 +625,70 @@ class AppInstallProcessorTest { ) } + private fun createEnqueueAppInstall() = AppInstall( + id = "123", + status = Status.AWAITING, + downloadURLList = mutableListOf("https://example.org/app.apk"), + packageName = "com.example.app", + type = Type.PWA, + source = Source.PWA + ) + + private fun createApplication(status: Status) = Application( + _id = "123", + name = "Test app", + package_name = "com.example.app", + status = status, + source = Source.PWA, + type = Type.PWA, + latest_version_code = 1L, + isFree = true, + isSystemApp = true, + url = "https://example.org/app.apk" + ) + + private fun createExpectedAppInstall(application: Application) = AppInstall( + application._id, + application.source, + application.status, + application.name, + application.package_name, + mutableListOf(), + mutableMapOf(), + application.status, + application.type, + application.icon_image_path, + application.latest_version_code, + application.offer_type, + application.isFree, + application.originalSize + ).also { + it.contentRating = application.contentRating + if (it.type == Type.PWA || application.source == Source.SYSTEM_APP) { + it.downloadURLList = mutableListOf(application.url) + } + } + + private fun mockInstallWorkManagerSuccess() { + mockkObject(InstallWorkManager) + isInstallWorkManagerMocked = true + every { InstallWorkManager.getUniqueWorkName(any()) } answers { callOriginal() } + every { InstallWorkManager.enqueueWork(any(), any()) } returns successfulOperation() + } + + private fun mockInstallWorkManagerFailure() { + mockkObject(InstallWorkManager) + isInstallWorkManagerMocked = true + every { InstallWorkManager.getUniqueWorkName(any()) } answers { callOriginal() } + every { InstallWorkManager.enqueueWork(any(), any()) } throws RuntimeException("enqueue failed") + } + + private fun successfulOperation(): Operation { + val operation = mock() + whenever(operation.result).thenReturn(Futures.immediateFuture(Operation.SUCCESS)) + return operation + } + private fun everyNetworkAvailable() { val connectivityManager = mock() val network = mock() diff --git a/app/src/test/java/foundation/e/apps/installProcessor/FakeAppInstallDAO.kt b/app/src/test/java/foundation/e/apps/installProcessor/FakeAppInstallDAO.kt index 93fc563e284bee99d826505c42b0c1d6873a7120..48749126c5984dd26ac468d17d99c8609e284a14 100644 --- a/app/src/test/java/foundation/e/apps/installProcessor/FakeAppInstallDAO.kt +++ b/app/src/test/java/foundation/e/apps/installProcessor/FakeAppInstallDAO.kt @@ -23,6 +23,8 @@ import androidx.lifecycle.asLiveData import foundation.e.apps.data.enums.Status import foundation.e.apps.data.install.AppInstallDAO import foundation.e.apps.data.install.models.AppInstall +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.flow class FakeAppInstallDAO : AppInstallDAO { @@ -36,6 +38,10 @@ class FakeAppInstallDAO : AppInstallDAO { TODO("Not yet implemented") } + override fun getDownloads(): Flow> { + return emptyFlow() + } + override suspend fun getDownloadList(): List { return appInstallList }