diff --git a/app/src/main/java/foundation/e/apps/data/install/AppManagerImpl.kt b/app/src/main/java/foundation/e/apps/data/install/AppManagerImpl.kt index 34aed8ea7c189c330417cb43cbb08365d20ba96d..5fcf1e074b86f970c8e552da120732d8f5ad8337 100644 --- a/app/src/main/java/foundation/e/apps/data/install/AppManagerImpl.kt +++ b/app/src/main/java/foundation/e/apps/data/install/AppManagerImpl.kt @@ -153,13 +153,7 @@ class AppManagerImpl @Inject constructor( } else if (status == Status.INSTALLING) { appInstall.downloadIdMap.all { true } appInstall.status = status - val isSelfUpdate = appInstall.packageName == context.packageName - if (isSelfUpdate) { - appInstallRepository.deleteDownload(appInstall) - } else { - appInstall.status = status - appInstallRepository.updateDownload(appInstall) - } + appInstallRepository.updateDownload(appInstall) installApp(appInstall) } } @@ -195,7 +189,7 @@ class AppManagerImpl @Inject constructor( list.sort() if (list.isEmpty()) { - reportInstallationIssue(appInstall) + finalizeUnattendedFailure(appInstall) val errorMessage = "installApp: Downloaded files doesn't exist for package ${appInstall.packageName} " Timber.e(errorMessage) @@ -214,7 +208,7 @@ class AppManagerImpl @Inject constructor( Timber.d("installApp: ENDED ${appInstall.name} ${list.size}") } catch (e: Exception) { Timber.e(">>> installApp app failed ${e.localizedMessage}") - reportInstallationIssue(appInstall) + finalizeUnattendedFailure(appInstall) throw e } } @@ -238,6 +232,26 @@ class AppManagerImpl @Inject constructor( } } + override suspend fun finalizeUnattendedFailure(appInstall: AppInstall) { + mutex.withLock { + Timber.w( + "Finalizing unattended failure for %s (%s) with %d tracked downloads", + appInstall.name, + appInstall.packageName, + appInstall.downloadIdMap.size + ) + appInstall.downloadIdMap.forEach { (key, _) -> + downloadManager.remove(key) + } + DownloadProgressLD.setDownloadId(-1) + + appInstall.downloadIdMap = mutableMapOf() + appInstall.status = Status.INSTALLATION_ISSUE + appInstallRepository.updateDownload(appInstall) + flushOldDownload(appInstall.packageName) + } + } + private suspend fun removeFusedDownload(appInstall: AppInstall) { appInstall.downloadIdMap.forEach { (key, _) -> downloadManager.remove(key) diff --git a/app/src/main/java/foundation/e/apps/data/install/core/InstallationEnqueuer.kt b/app/src/main/java/foundation/e/apps/data/install/core/InstallationEnqueuer.kt index 4e71cdcfb1359d413926067cdb8edaede85a8c59..a85bbe5baedd05e5834e622d2616e7840722c14d 100644 --- a/app/src/main/java/foundation/e/apps/data/install/core/InstallationEnqueuer.kt +++ b/app/src/main/java/foundation/e/apps/data/install/core/InstallationEnqueuer.kt @@ -77,7 +77,7 @@ class InstallationEnqueuer @Inject constructor( "Enqueuing App install work is failed for ${appInstall.packageName} " + "exception: ${throwable.localizedMessage}" ) - appManager.reportInstallationIssue(appInstall) + appManager.finalizeUnattendedFailure(appInstall) false } else -> throw throwable diff --git a/app/src/main/java/foundation/e/apps/data/install/download/DownloadManagerUtils.kt b/app/src/main/java/foundation/e/apps/data/install/download/DownloadManagerUtils.kt index ab4772a49ba9a7bc670621b202814116ee7984dc..862725c380948e41a19ffdf13be3e051101ced95 100644 --- a/app/src/main/java/foundation/e/apps/data/install/download/DownloadManagerUtils.kt +++ b/app/src/main/java/foundation/e/apps/data/install/download/DownloadManagerUtils.kt @@ -96,8 +96,7 @@ class DownloadManagerUtils @Inject constructor( } private suspend fun handleDownloadFailed(appInstall: AppInstall, downloadId: Long) { - appManager.reportInstallationIssue(appInstall) - appManager.cancelDownload(appInstall) + appManager.finalizeUnattendedFailure(appInstall) Timber.w("===> Download failed: ${appInstall.name} ${appInstall.status}") val hasInsufficientSpace = diff --git a/app/src/main/java/foundation/e/apps/data/install/pkg/InstallerService.kt b/app/src/main/java/foundation/e/apps/data/install/pkg/InstallerService.kt index 02aab689688dfe0b3f012565a6d51c6e498e9f40..d8b49d42f8e59e0c858791b736f4eddc296adc56 100644 --- a/app/src/main/java/foundation/e/apps/data/install/pkg/InstallerService.kt +++ b/app/src/main/java/foundation/e/apps/data/install/pkg/InstallerService.kt @@ -135,10 +135,15 @@ class InstallerService : Service() { private fun updateInstallationIssue(pkgName: String) { if (pkgName.isEmpty()) { Timber.d("updateDownloadStatus: package name should not be empty!") + return } coroutineScope.launch { val fusedDownload = appManager.getFusedDownload(packageName = pkgName) - appManager.reportInstallationIssue(fusedDownload) + if (fusedDownload.id.isEmpty()) { + Timber.w("Unable to finalize install failure for unresolved package %s", pkgName) + return@launch + } + appManager.finalizeUnattendedFailure(fusedDownload) } } } diff --git a/app/src/main/java/foundation/e/apps/data/install/pkg/PkgManagerBR.kt b/app/src/main/java/foundation/e/apps/data/install/pkg/PkgManagerBR.kt index bbf12846d9594cd48be3812ccb34fb9633a89651..48ac9e54c2844a062b0e0d3f852974707903d79c 100644 --- a/app/src/main/java/foundation/e/apps/data/install/pkg/PkgManagerBR.kt +++ b/app/src/main/java/foundation/e/apps/data/install/pkg/PkgManagerBR.kt @@ -109,7 +109,11 @@ class PkgManagerBR @Inject constructor( private fun updateInstallationIssue(pkgName: String) { coroutineScope.launch { val fusedDownload = appManager.getFusedDownload(packageName = pkgName) - appManager.reportInstallationIssue(fusedDownload) + if (fusedDownload.id.isEmpty()) { + Timber.w("Unable to finalize broadcast install failure for unresolved package %s", pkgName) + return@launch + } + appManager.finalizeUnattendedFailure(fusedDownload) } } } 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..518b764e1b392ef4041f61e5785f9ec011fce6e7 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 @@ -88,7 +88,7 @@ class InstallOrchestrator @Inject constructor( throwable, "INSTALL: Failed to enqueue unique work for ${download.name}: $uniqueWorkName" ) - appManager.reportInstallationIssue(download) + appManager.finalizeUnattendedFailure(download) } } @@ -125,7 +125,7 @@ class InstallOrchestrator @Inject constructor( Timber.i( "INSTALL: Marking stale download as issue for ${app.name}/${app.packageName}" ) - appManager.reportInstallationIssue(app) + appManager.finalizeUnattendedFailure(app) } } }.onFailure { throwable -> 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 0f2465ea66402bb031716da0e2a2fd7400f54f3c..c94d2a6a22f464b2e0b8f08dc69a9722b4c78395 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 @@ -248,13 +248,22 @@ class UpdatesFragment : TimeoutFragment(R.layout.fragment_updates), ApplicationI val noUpdateJobIsRunning = !hasBlockingUpdateWork() - val noInstallJobIsRunning = !hasActiveRelevantWork( - taggedInstallWorkInfos + legacyInstallWorkInfos - ) + val noInstallJobIsRunning = !hasActiveRelevantInstallWork() setButtonEnabled(noUpdateJobIsRunning && noInstallJobIsRunning) } + private fun hasActiveRelevantInstallWork(): Boolean { + val relevantAppIds = displayedUpdates.map { it._id }.filter { it.isNotBlank() }.toSet() + if (relevantAppIds.isEmpty()) return false + + return hasActiveRelevantWork( + (taggedInstallWorkInfos + legacyInstallWorkInfos).filter { workInfo -> + workInfo.tags.any(relevantAppIds::contains) + } + ) + } + private fun hasBlockingUpdateWork(): Boolean { val hasRunningPeriodicUpdateWork = periodicUpdateWorkInfos.any { it.state == WorkInfo.State.RUNNING diff --git a/app/src/test/java/foundation/e/apps/data/install/AppManagerImplTest.kt b/app/src/test/java/foundation/e/apps/data/install/AppManagerImplTest.kt index 9ebe81c8e8eefed6767c1739c74d6b4e9c742570..07303fe7a3b7882aba703e03104e13cdee69c46b 100644 --- a/app/src/test/java/foundation/e/apps/data/install/AppManagerImplTest.kt +++ b/app/src/test/java/foundation/e/apps/data/install/AppManagerImplTest.kt @@ -339,6 +339,84 @@ class AppManagerImplTest { verify(exactly = 2) { downloadManager.remove(51L) } } + @Test + fun finalizeUnattendedFailure_clearsPendingStateAndKeepsRetryableEntry() = runTest { + val appInstall = createAppInstall(packageName = "com.example.failed").apply { + status = Status.DOWNLOADING + downloadIdMap = mutableMapOf(61L to false, 62L to true) + } + appInstallDAO.addDownload(appInstall) + createApk(appInstall.packageName, "cached.apk") + DownloadProgressLD.setDownloadId(61L) + + appManager.finalizeUnattendedFailure(appInstall) + + val storedAppInstall = appInstallDAO.getDownloadById(appInstall.id) + assertThat(storedAppInstall?.status).isEqualTo(Status.INSTALLATION_ISSUE) + assertThat(storedAppInstall?.downloadIdMap).isEqualTo(emptyMap()) + assertThat(File(tempDir, appInstall.packageName).exists()).isFalse() + assertThat(DownloadProgressLD.downloadId).isEmpty() + verify { downloadManager.remove(61L) } + verify { downloadManager.remove(62L) } + } + + @Test + fun finalizeUnattendedFailure_keepsOtherActiveAppsUntouched() = runTest { + val failedApp = createAppInstall(id = "131", packageName = "com.example.failed").apply { + status = Status.INSTALLING + downloadIdMap = mutableMapOf(71L to true) + } + val otherApp = createAppInstall(id = "132", packageName = "com.example.other").apply { + status = Status.DOWNLOADING + downloadIdMap = mutableMapOf(81L to false) + } + appInstallDAO.addDownload(failedApp) + appInstallDAO.addDownload(otherApp) + + appManager.finalizeUnattendedFailure(failedApp) + + assertThat(appInstallDAO.getDownloadById(failedApp.id)?.status) + .isEqualTo(Status.INSTALLATION_ISSUE) + assertThat(appInstallDAO.getDownloadById(failedApp.id)?.downloadIdMap) + .isEqualTo(emptyMap()) + assertThat(appInstallDAO.getDownloadById(otherApp.id)?.status).isEqualTo(Status.DOWNLOADING) + assertThat(appInstallDAO.getDownloadById(otherApp.id)?.downloadIdMap) + .containsExactlyEntriesIn(otherApp.downloadIdMap) + verify(exactly = 1) { downloadManager.remove(71L) } + verify(exactly = 0) { downloadManager.remove(81L) } + } + + @Test + fun finalizeUnattendedFailure_isIdempotentAndRemovesTrackedIds() = runTest { + val appInstall = createAppInstall(id = "141", packageName = "com.example.repeat.failure").apply { + status = Status.DOWNLOADING + downloadIdMap = mutableMapOf(91L to false) + } + appInstallDAO.addDownload(appInstall) + + appManager.finalizeUnattendedFailure(appInstall) + appManager.finalizeUnattendedFailure(appInstall) + + assertThat(appInstallDAO.getDownloadById(appInstall.id)?.status) + .isEqualTo(Status.INSTALLATION_ISSUE) + assertThat(appManager.getFusedDownload(91L).id).isEmpty() + verify(exactly = 1) { downloadManager.remove(91L) } + } + + @Test + fun updateDownloadStatus_installingKeepsSelfUpdateEntryForDelayedCallbacks() = runTest { + val appInstall = createAppInstall(id = "151", packageName = context.packageName).apply { + status = Status.DOWNLOADED + downloadIdMap = mutableMapOf(101L to true) + } + appInstallDAO.addDownload(appInstall) + createApk(appInstall.packageName, "${appInstall.packageName}_1.apk") + + appManager.updateDownloadStatus(appInstall, Status.INSTALLING) + + assertThat(appInstallDAO.getDownloadById(appInstall.id)?.status).isEqualTo(Status.INSTALLING) + } + private fun initTest(hasAnyExistingWork: Boolean = false): AppInstall { mockkObject(InstallWorkManager) every { InstallWorkManager.checkWorkIsAlreadyAvailable(any(), any()) } returns hasAnyExistingWork diff --git a/app/src/test/java/foundation/e/apps/data/install/download/DownloadManagerUtilsTest.kt b/app/src/test/java/foundation/e/apps/data/install/download/DownloadManagerUtilsTest.kt index 2c9c5d7107a330b218d937e0b9e16f880c8a7dd1..0e474dc2bd563d0d09b71a69707e07ae851a5812 100644 --- a/app/src/test/java/foundation/e/apps/data/install/download/DownloadManagerUtilsTest.kt +++ b/app/src/test/java/foundation/e/apps/data/install/download/DownloadManagerUtilsTest.kt @@ -122,6 +122,44 @@ class DownloadManagerUtilsTest { coVerify { appManagerWrapper.getFusedDownload(99L, any()) } coVerify(exactly = 0) { appManagerWrapper.updateAppInstall(any()) } + coVerify(exactly = 0) { appManagerWrapper.finalizeUnattendedFailure(any()) } + coVerify(exactly = 0) { appManagerWrapper.reportInstallationIssue(any()) } + coVerify(exactly = 0) { appManagerWrapper.cancelDownload(any(), any()) } + } + + @OptIn(DelicateCoroutinesApi::class) + @Test + fun updateDownloadStatus_finalizesFailedDownloadThroughCanonicalFinalizer() = runTest { + val context = mockk(relaxed = true) + val appManagerWrapper = mockk(relaxed = true) + val downloadManager = mockk(relaxed = true) + val storageNotificationManager = mockk(relaxed = true) + val appInstall = AppInstall( + id = "download-id", + name = "Test App", + packageName = "com.example.app", + source = InstallationSource.PLAY_STORE, + status = Status.DOWNLOADING, + downloadURLList = mutableListOf("https://example.org/main.apk"), + downloadIdMap = mutableMapOf(1L to false) + ) + val utils = DownloadManagerUtils( + context, + appManagerWrapper, + downloadManager, + storageNotificationManager, + backgroundScope + ) + + coEvery { appManagerWrapper.getFusedDownload(1L, any()) } returns appInstall + every { downloadManager.hasDownloadFailed(1L) } returns true + every { downloadManager.getDownloadFailureReason(1L) } returns PlatformDownloadManager.ERROR_UNKNOWN + + utils.updateDownloadStatus(1L) + advanceTimeBy(1500) + runCurrent() + + coVerify { appManagerWrapper.finalizeUnattendedFailure(appInstall) } coVerify(exactly = 0) { appManagerWrapper.reportInstallationIssue(any()) } coVerify(exactly = 0) { appManagerWrapper.cancelDownload(any(), any()) } } diff --git a/app/src/test/java/foundation/e/apps/data/install/pkg/InstallerServiceTest.kt b/app/src/test/java/foundation/e/apps/data/install/pkg/InstallerServiceTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..3aba8e1d35a785e713243146373a61f249c1b659 --- /dev/null +++ b/app/src/test/java/foundation/e/apps/data/install/pkg/InstallerServiceTest.kt @@ -0,0 +1,59 @@ +/* + * 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.pkg + +import android.content.Intent +import android.content.pm.PackageInstaller +import androidx.test.core.app.ApplicationProvider +import foundation.e.apps.data.application.AppManager +import foundation.e.apps.data.faultyApps.FaultyAppRepository +import foundation.e.apps.data.installation.model.AppInstall +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +class InstallerServiceTest { + + @Test + fun onStartCommand_finalizesResolvedInstallFailure() = runTest { + val service = InstallerService().apply { + appManager = mockk(relaxed = true) + appLoungePackageManager = mockk(relaxed = true) + faultyAppRepository = mockk(relaxed = true) + coroutineScope = this@runTest + } + val appInstall = AppInstall(id = "app-id", packageName = "org.signal") + coEvery { service.appManager.getFusedDownload(packageName = "org.signal") } returns appInstall + val intent = Intent(ApplicationProvider.getApplicationContext(), InstallerService::class.java) + .putExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE) + .putExtra(PackageInstaller.EXTRA_PACKAGE_NAME, "org.signal") + + service.onStartCommand(intent, 0, 0) + advanceUntilIdle() + + coVerify { service.appManager.finalizeUnattendedFailure(appInstall) } + } +} diff --git a/app/src/test/java/foundation/e/apps/data/install/pkg/PkgManagerBRTest.kt b/app/src/test/java/foundation/e/apps/data/install/pkg/PkgManagerBRTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..f8aecea6e62c4fbd3ad4d2a92b2d5d39061780b5 --- /dev/null +++ b/app/src/test/java/foundation/e/apps/data/install/pkg/PkgManagerBRTest.kt @@ -0,0 +1,61 @@ +/* + * 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.pkg + +import android.content.Intent +import android.net.Uri +import androidx.test.core.app.ApplicationProvider +import foundation.e.apps.data.application.AppManager +import foundation.e.apps.data.faultyApps.FaultyAppRepository +import foundation.e.apps.data.installation.model.AppInstall +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [android.os.Build.VERSION_CODES.R]) +class PkgManagerBRTest { + + @Test + fun onReceive_finalizesResolvedBroadcastInstallFailure() = runTest { + val appManager = mockk(relaxed = true) + val appInstall = AppInstall(id = "app-id", packageName = "org.signal") + coEvery { appManager.getFusedDownload(packageName = "org.signal") } returns appInstall + val receiver = PkgManagerBR( + appManager = appManager, + appLoungePackageManager = mockk(relaxed = true), + faultyAppRepository = mockk(relaxed = true), + coroutineScope = this, + ) + val intent = Intent(AppLoungePackageManager.ERROR_PACKAGE_INSTALL) + .setData(Uri.parse("package:org.signal")) + + receiver.onReceive(ApplicationProvider.getApplicationContext(), intent) + advanceUntilIdle() + + coVerify { appManager.finalizeUnattendedFailure(appInstall) } + } +} diff --git a/app/src/test/java/foundation/e/apps/data/install/updates/UpdatesWorkManagerTest.kt b/app/src/test/java/foundation/e/apps/data/install/updates/UpdatesWorkManagerTest.kt index 9ab6b1ce75a345d18fe5ad3f624e12fddcf6df49..ba2160838472d48d6bad4637f38459fb21b40610 100644 --- a/app/src/test/java/foundation/e/apps/data/install/updates/UpdatesWorkManagerTest.kt +++ b/app/src/test/java/foundation/e/apps/data/install/updates/UpdatesWorkManagerTest.kt @@ -123,6 +123,32 @@ class UpdatesWorkManagerTest { assertThat(replacedWorkSpec.intervalDuration).isEqualTo(TimeUnit.HOURS.toMillis(24)) } + @Test + fun startUpdateAllWork_canceledUserWorkIsNotLeftActive() { + UpdatesWorkManager.startUpdateAllWork(context) + val workInfo = getActiveUniqueWorkInfo("updates_work_user") + + workManager.cancelWorkById(workInfo.id).result.get() + + val activeWorkInfos = workManager.getWorkInfosByTag( + UpdatesWorkManager.TAG_WORK_USER_INITIATED_UPDATE + ).get().filter { !it.state.isFinished } + assertThat(activeWorkInfos).isEmpty() + } + + @Test + fun enqueueWork_canceledPeriodicWorkIsNotLeftActive() { + UpdatesWorkManager.enqueueWork(context, interval = 6, ExistingPeriodicWorkPolicy.REPLACE) + val workInfo = getActiveUniqueWorkInfo("updates_work") + + workManager.cancelWorkById(workInfo.id).result.get() + + val activeWorkInfos = workManager.getWorkInfosByTag( + UpdatesWorkManager.TAG_WORK_PERIODIC_UPDATE + ).get().filter { !it.state.isFinished } + assertThat(activeWorkInfos).isEmpty() + } + private fun getActiveUniqueWorkInfo(uniqueWorkName: String): WorkInfo { return workManager.getWorkInfosForUniqueWork(uniqueWorkName).get() .single { !it.state.isFinished } 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 c130a60a320d9b99c535417da1a66d7e10e53841..42d008dddea9af4f8c5eada6e76afd561d093612 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 @@ -104,7 +104,7 @@ class InstallOrchestratorTest { installOrchestrator.init() advanceUntilIdle() - verifyMockito(appManager).reportInstallationIssue(app) + verifyMockito(appManager).finalizeUnattendedFailure(app) verifyMockito(appManager, never()).updateDownloadStatus(any(), any()) } @@ -193,7 +193,7 @@ class InstallOrchestratorTest { installOrchestrator.init() advanceUntilIdle() - verifyMockito(appManager, times(1)).reportInstallationIssue(eq(awaiting)) + verifyMockito(appManager, times(1)).finalizeUnattendedFailure(eq(awaiting)) } @Test diff --git a/app/src/test/java/foundation/e/apps/installProcessor/FakeAppManager.kt b/app/src/test/java/foundation/e/apps/installProcessor/FakeAppManager.kt index 7dc636ba5957de1c52188f561ebad8db08382c28..8dfadb35afa7495ea624adeae010f1154d53872e 100644 --- a/app/src/test/java/foundation/e/apps/installProcessor/FakeAppManager.kt +++ b/app/src/test/java/foundation/e/apps/installProcessor/FakeAppManager.kt @@ -102,6 +102,11 @@ class FakeAppManager( fusedDownloadDAO.deleteDownload(appInstall) } + override suspend fun finalizeUnattendedFailure(appInstall: AppInstall) { + appInstall.downloadIdMap = mutableMapOf() + reportInstallationIssue(appInstall) + } + override suspend fun getFusedDownload(downloadId: Long, packageName: String): AppInstall { return fusedDownloadDAO.getDownloadList().firstOrNull { (downloadId != 0L && it.downloadIdMap.containsKey(downloadId)) || diff --git a/app/src/test/java/foundation/e/apps/installProcessor/InstallationEnqueuerTest.kt b/app/src/test/java/foundation/e/apps/installProcessor/InstallationEnqueuerTest.kt index bcae96a83e84c192c186502b940fb499c6d6b0b5..bc2bd88c47a2b6e35268da36ae4472878bd73070 100644 --- a/app/src/test/java/foundation/e/apps/installProcessor/InstallationEnqueuerTest.kt +++ b/app/src/test/java/foundation/e/apps/installProcessor/InstallationEnqueuerTest.kt @@ -269,7 +269,7 @@ class InstallationEnqueuerTest { val result = enqueuer.enqueue(appInstall) assertFalse(result) - coVerify { appManager.reportInstallationIssue(appInstall) } + coVerify { appManager.finalizeUnattendedFailure(appInstall) } } @Test diff --git a/app/src/test/java/foundation/e/apps/ui/MainActivityViewModelTest.kt b/app/src/test/java/foundation/e/apps/ui/MainActivityViewModelTest.kt index e17fc3125c6cd9ddf3b8a3dde9aac6ca7f04a215..06a26ce4342e41def0d01253f7e260be56ff8898 100644 --- a/app/src/test/java/foundation/e/apps/ui/MainActivityViewModelTest.kt +++ b/app/src/test/java/foundation/e/apps/ui/MainActivityViewModelTest.kt @@ -26,6 +26,8 @@ import foundation.e.apps.data.application.data.Application import foundation.e.apps.data.install.core.AppInstallationFacade import foundation.e.apps.data.install.pkg.AppLoungePackageManager import foundation.e.apps.data.install.pkg.PwaManager +import foundation.e.apps.data.installation.model.AppInstall +import foundation.e.apps.domain.model.install.Status import foundation.e.apps.domain.application.ApplicationDomain import foundation.e.apps.domain.install.GetOtherStoreUpdateConfirmationUseCase import foundation.e.apps.domain.install.OtherStoreUpdateConfirmation @@ -181,6 +183,25 @@ class MainActivityViewModelTest { coVerify(exactly = 0) { appInstallationFacade.initAppInstall(any(), any()) } } + @Test + fun updateStatusOfFusedApps_prefersRetryableFailureFromDownloadState() { + val app = Application( + _id = "app-id", + name = "Signal", + package_name = "org.signal", + ) + val appInstall = AppInstall( + id = "app-id", + packageName = "org.signal", + status = Status.INSTALLATION_ISSUE, + ) + every { applicationRepository.getFusedAppInstallationStatus(app) } returns Status.UPDATABLE + + viewModel.updateStatusOfFusedApps(listOf(app), listOf(appInstall)) + + assertThat(app.status).isEqualTo(Status.INSTALLATION_ISSUE) + } + /** * [MutableLiveData.postValue] dispatches to the main thread asynchronously. * With [InstantTaskExecutorRule], it still needs a brief yield for the diff --git a/app/src/test/java/foundation/e/apps/ui/compose/state/InstallButtonStateMapperTest.kt b/app/src/test/java/foundation/e/apps/ui/compose/state/InstallButtonStateMapperTest.kt index 0b244b7d188a7dca868d58b8372341f76e110ca6..dc042fa4d79470c6a050ecfd1cbeb19b42d2f32e 100644 --- a/app/src/test/java/foundation/e/apps/ui/compose/state/InstallButtonStateMapperTest.kt +++ b/app/src/test/java/foundation/e/apps/ui/compose/state/InstallButtonStateMapperTest.kt @@ -294,6 +294,21 @@ class InstallButtonStateMapperTest { assertEquals(R.string.update, incompatibleState.label.resId) } + @Test + fun installation_issue_nonFaulty_staysEnabled_withRetryLabel() { + val state = mapAppToInstallState( + input = defaultInput( + app = baseApp(Status.INSTALLATION_ISSUE), + installationFault = InstallationFault(isFaulty = false, reason = ""), + ), + ) + + assertTrue(state.enabled) + assertEquals(R.string.retry, state.label.resId) + assertEquals(InstallButtonAction.Install, state.actionIntent) + assertEquals(StatusTag.InstallationIssue, state.statusTag) + } + @Test fun override_status_uses_resolved_status_for_downloading_raw_status() { val state = mapAppToInstallState( diff --git a/data/src/main/java/foundation/e/apps/data/application/AppManager.kt b/data/src/main/java/foundation/e/apps/data/application/AppManager.kt index 1812b53fa52ff036b0513fd777b192e6d8d06086..b331bdba981fa3cffc4d30967231c5a4796abb54 100644 --- a/data/src/main/java/foundation/e/apps/data/application/AppManager.kt +++ b/data/src/main/java/foundation/e/apps/data/application/AppManager.kt @@ -44,6 +44,8 @@ interface AppManager { suspend fun cancelDownload(appInstall: AppInstall, packageName: String = "") + suspend fun finalizeUnattendedFailure(appInstall: AppInstall) + suspend fun getFusedDownload(downloadId: Long = 0, packageName: String = ""): AppInstall fun flushOldDownload(packageName: String) diff --git a/data/src/main/java/foundation/e/apps/data/installation/core/InstallationProcessor.kt b/data/src/main/java/foundation/e/apps/data/installation/core/InstallationProcessor.kt index 5e24a1f80134b03b26e3cb90f5d78c670c088ea3..18da06f9cd5200d035bab598f9a36531f45dc48e 100644 --- a/data/src/main/java/foundation/e/apps/data/installation/core/InstallationProcessor.kt +++ b/data/src/main/java/foundation/e/apps/data/installation/core/InstallationProcessor.kt @@ -61,7 +61,7 @@ class InstallationProcessor @Inject constructor( } if (!validateDownload(appInstall)) { - appManager.reportInstallationIssue(appInstall) + appManager.finalizeUnattendedFailure(appInstall) val message = "Installation issue for ${appInstall.name}/${appInstall.packageName}" Timber.w(message) @@ -92,8 +92,7 @@ class InstallationProcessor @Inject constructor( "Install worker failed for ${appInstall.packageName} exception: ${exception.message}" ) - appManager.reportInstallationIssue(appInstall) - appManager.cancelDownload(appInstall) + appManager.finalizeUnattendedFailure(appInstall) } } @@ -160,7 +159,7 @@ class InstallationProcessor @Inject constructor( "Handling install status is failed for ${download.packageName} " + "exception: ${throwable.localizedMessage}" Timber.e(throwable, message) - appManager.reportInstallationIssue(download) + appManager.finalizeUnattendedFailure(download) finishInstallation(download, isUpdateWork) }