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 index 8cde1a8e348a8cf1eafca07c900aa981170abd2b..6e2dcc925a48b674f2e7ad52cd55c08bbca149ca 100644 --- 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 @@ -23,6 +23,7 @@ import androidx.work.WorkInfo import androidx.work.WorkManager import androidx.work.await import dagger.hilt.android.qualifiers.ApplicationContext +import foundation.e.apps.data.DownloadManager import foundation.e.apps.data.application.AppManager import foundation.e.apps.data.di.qualifiers.IoCoroutineScope import foundation.e.apps.data.installation.model.AppInstall @@ -36,12 +37,14 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import timber.log.Timber import javax.inject.Inject +import android.app.DownloadManager as PlatformDownloadManager class InstallOrchestrator @Inject constructor( @param:ApplicationContext val context: Context, @param:IoCoroutineScope private val scope: CoroutineScope, private val appManager: AppManager, - private val appInstallRepository: AppInstallRepository + private val appInstallRepository: AppInstallRepository, + private val downloadManager: DownloadManager, ) { fun init() { @@ -117,6 +120,8 @@ class InstallOrchestrator @Inject constructor( when { hasActiveWork || hasActiveSession -> return@forEach + app.status == Status.DOWNLOADING && reconcileStaleDownload(app, workManager) -> + return@forEach appManager.isAppInstalled(app) -> { appManager.updateDownloadStatus(app, Status.INSTALLED) } @@ -137,6 +142,50 @@ class InstallOrchestrator @Inject constructor( } } + private suspend fun reconcileStaleDownload( + app: AppInstall, + workManager: WorkManager + ): Boolean { + val downloadIds = app.downloadIdMap.keys + val statuses = downloadIds.map { id -> downloadManager.isDownloadSuccessful(id).second } + val hasRunningOrPending = statuses.any { + it == PlatformDownloadManager.STATUS_PENDING || + it == PlatformDownloadManager.STATUS_RUNNING + } + val allSuccessful = statuses.isNotEmpty() && + statuses.all { it == PlatformDownloadManager.STATUS_SUCCESSFUL } + + return when { + downloadIds.isEmpty() -> { + markDownloadAsIssue(app, workManager, "no download ids") + true + } + hasRunningOrPending -> false + allSuccessful -> { + app.status = Status.DOWNLOADED + appManager.updateAppInstall(app) + Timber.i("INSTALL: Marked ${app.name}/${app.packageName} as DOWNLOADED after reconciliation") + true + } + else -> { + markDownloadAsIssue(app, workManager, "downloads are paused/failed") + true + } + } + } + + private suspend fun markDownloadAsIssue( + app: AppInstall, + workManager: WorkManager, + reason: String + ) { + workManager.cancelAllWorkByTag(app.id) + Timber.i( + "INSTALL: Marking stale download as issue for ${app.name}/${app.packageName} ($reason)" + ) + appManager.reportInstallationIssue(app) + } + private fun hasActiveInstallSession(packageName: String): Boolean { return context.packageManager.packageInstaller.allSessions.any { session -> session.appPackageName == packageName && session.isActive 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 index a453e3ed4477f378982f49839ffcf0726810408e..0319bc13c3cee1a05ab6be9603c6af863c744781 100644 --- a/app/src/test/java/foundation/e/apps/install/workmanager/InstallOrchestratorTest.kt +++ b/app/src/test/java/foundation/e/apps/install/workmanager/InstallOrchestratorTest.kt @@ -31,6 +31,7 @@ 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.DownloadManager import foundation.e.apps.data.application.AppManager import foundation.e.apps.data.installation.model.AppInstall import foundation.e.apps.data.installation.repository.AppInstallRepository @@ -63,6 +64,7 @@ import org.mockito.kotlin.whenever import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config import java.util.concurrent.TimeUnit +import android.app.DownloadManager as PlatformDownloadManager @OptIn(ExperimentalCoroutinesApi::class) @RunWith(RobolectricTestRunner::class) @@ -75,12 +77,15 @@ class InstallOrchestratorTest { private val context: Context = ApplicationProvider.getApplicationContext() private val appInstallRepository = mock() private val appManager = mock() + private val downloadManager = mock() private var isInstallWorkManagerMocked = false @Before fun setup() { WorkManagerTestInitHelper.initializeTestWorkManager(context) + whenever(downloadManager.isDownloadSuccessful(any())) + .thenReturn(Pair(false, PlatformDownloadManager.STATUS_RUNNING)) } @After @@ -98,7 +103,8 @@ class InstallOrchestratorTest { whenever(appInstallRepository.getDownloads()).thenReturn(flowOf(listOf(app)), emptyFlow()) whenever(appManager.isAppInstalled(app)).thenReturn(false) - val installOrchestrator = InstallOrchestrator(context, this, appManager, appInstallRepository) + val installOrchestrator = + InstallOrchestrator(context, this, appManager, appInstallRepository, downloadManager) installOrchestrator.init() advanceUntilIdle() @@ -107,6 +113,46 @@ class InstallOrchestratorTest { verifyMockito(appManager, never()).updateDownloadStatus(any(), any()) } + @Test + fun init_marksStaleDownloadAsInstallationIssue_whenDownloadPaused() = runTest { + val app = createAppInstall(status = Status.DOWNLOADING).apply { + downloadIdMap = mutableMapOf(11L to false, 12L to false) + } + + whenever(downloadManager.isDownloadSuccessful(any())) + .thenReturn(Pair(false, PlatformDownloadManager.STATUS_PAUSED)) + whenever(appInstallRepository.getDownloads()).thenReturn(flowOf(listOf(app)), emptyFlow()) + + val installOrchestrator = + InstallOrchestrator(context, this, appManager, appInstallRepository, downloadManager) + + installOrchestrator.init() + advanceUntilIdle() + + verifyMockito(appManager).reportInstallationIssue(app) + verifyMockito(appManager, never()).updateAppInstall(any()) + } + + @Test + fun init_marksDownloadAsDownloaded_whenAllItemsSuccessful() = runTest { + val app = createAppInstall(status = Status.DOWNLOADING).apply { + downloadIdMap = mutableMapOf(21L to false, 22L to false) + } + + whenever(downloadManager.isDownloadSuccessful(any())) + .thenReturn(Pair(true, PlatformDownloadManager.STATUS_SUCCESSFUL)) + whenever(appInstallRepository.getDownloads()).thenReturn(flowOf(listOf(app)), emptyFlow()) + + val installOrchestrator = + InstallOrchestrator(context, this, appManager, appInstallRepository, downloadManager) + + installOrchestrator.init() + advanceUntilIdle() + + verifyMockito(appManager).updateAppInstall(app) + verifyMockito(appManager, never()).reportInstallationIssue(any()) + } + @Test fun init_updatesStatusToInstalledWhenAppAlreadyInstalled() = runTest { val app = createAppInstall(status = Status.DOWNLOADED) @@ -114,7 +160,8 @@ class InstallOrchestratorTest { whenever(appInstallRepository.getDownloads()).thenReturn(flowOf(listOf(app)), emptyFlow()) whenever(appManager.isAppInstalled(app)).thenReturn(true) - val installOrchestrator = InstallOrchestrator(context, this, appManager, appInstallRepository) + val installOrchestrator = + InstallOrchestrator(context, this, appManager, appInstallRepository, downloadManager) installOrchestrator.init() advanceUntilIdle() @@ -139,7 +186,8 @@ class InstallOrchestratorTest { whenever(packageInstaller.allSessions).thenReturn(listOf(sessionInfo)) whenever(appInstallRepository.getDownloads()).thenReturn(flowOf(listOf(app)), emptyFlow()) - val installOrchestrator = InstallOrchestrator(wrappedContext, this, appManager, appInstallRepository) + val installOrchestrator = + InstallOrchestrator(wrappedContext, this, appManager, appInstallRepository, downloadManager) installOrchestrator.init() advanceUntilIdle() @@ -155,7 +203,8 @@ class InstallOrchestratorTest { whenever(appInstallRepository.getDownloads()).thenReturn(flowOf(emptyList()), flowOf(listOf(awaiting))) - val installOrchestrator = InstallOrchestrator(context, this, appManager, appInstallRepository) + val installOrchestrator = + InstallOrchestrator(context, this, appManager, appInstallRepository, downloadManager) installOrchestrator.init() advanceUntilIdle() @@ -172,7 +221,8 @@ class InstallOrchestratorTest { whenever(appInstallRepository.getDownloads()) .thenReturn(flowOf(emptyList()), flowOf(listOf(active, awaiting))) - val installOrchestrator = InstallOrchestrator(context, this, appManager, appInstallRepository) + val installOrchestrator = + InstallOrchestrator(context, this, appManager, appInstallRepository, downloadManager) installOrchestrator.init() advanceUntilIdle() @@ -187,7 +237,8 @@ class InstallOrchestratorTest { whenever(appInstallRepository.getDownloads()).thenReturn(flowOf(emptyList()), flowOf(listOf(awaiting))) - val installOrchestrator = InstallOrchestrator(context, this, appManager, appInstallRepository) + val installOrchestrator = + InstallOrchestrator(context, this, appManager, appInstallRepository, downloadManager) installOrchestrator.init() advanceUntilIdle() @@ -202,7 +253,8 @@ class InstallOrchestratorTest { whenever(appInstallRepository.getDownloads()).thenReturn(flowOf(emptyList()), flowOf(listOf(awaiting))) - val installOrchestrator = InstallOrchestrator(context, this, appManager, appInstallRepository) + val installOrchestrator = + InstallOrchestrator(context, this, appManager, appInstallRepository, downloadManager) installOrchestrator.init() advanceUntilIdle() @@ -221,7 +273,8 @@ class InstallOrchestratorTest { whenever(appInstallRepository.getDownloads()).thenReturn(flowOf(listOf(app)), emptyFlow()) - val installOrchestrator = InstallOrchestrator(context, this, appManager, appInstallRepository) + val installOrchestrator = + InstallOrchestrator(context, this, appManager, appInstallRepository, downloadManager) installOrchestrator.init() advanceUntilIdle() @@ -238,7 +291,8 @@ class InstallOrchestratorTest { whenever(appInstallRepository.getDownloads()).thenReturn(flowOf(emptyList()), flowOf(listOf(awaiting))) - val installOrchestrator = InstallOrchestrator(context, this, appManager, appInstallRepository) + val installOrchestrator = + InstallOrchestrator(context, this, appManager, appInstallRepository, downloadManager) installOrchestrator.init() advanceUntilIdle() @@ -255,7 +309,8 @@ class InstallOrchestratorTest { .thenThrow(RuntimeException("reconcile failed")) .thenReturn(flowOf(listOf(awaiting))) - val installOrchestrator = InstallOrchestrator(context, this, appManager, appInstallRepository) + val installOrchestrator = + InstallOrchestrator(context, this, appManager, appInstallRepository, downloadManager) installOrchestrator.init() advanceUntilIdle() @@ -267,7 +322,8 @@ class InstallOrchestratorTest { fun init_stopsAfterCancellationExceptionDuringReconciliation() = runTest { whenever(appInstallRepository.getDownloads()).thenThrow(CancellationException("cancel reconcile")) - val installOrchestrator = InstallOrchestrator(context, this, appManager, appInstallRepository) + val installOrchestrator = + InstallOrchestrator(context, this, appManager, appInstallRepository, downloadManager) installOrchestrator.init() advanceUntilIdle()