diff --git a/app/src/main/java/foundation/e/apps/data/application/UpdatesDao.kt b/app/src/main/java/foundation/e/apps/data/application/UpdatesDao.kt index ea5029841c418ec27096deaf70a0f39329b5e219..31633863498292f76940ce50dc786ed85efeb597 100644 --- a/app/src/main/java/foundation/e/apps/data/application/UpdatesDao.kt +++ b/app/src/main/java/foundation/e/apps/data/application/UpdatesDao.kt @@ -22,12 +22,14 @@ import foundation.e.apps.data.installation.model.AppInstall object UpdatesDao { private val _appsAwaitingForUpdate: MutableList = mutableListOf() - val appsAwaitingForUpdate: List = _appsAwaitingForUpdate + val appsAwaitingForUpdate: List + get() = _appsAwaitingForUpdate.toList() var appsAwaitingForUpdateIncludesOtherStores: Boolean = false private set private val _successfulUpdatedApps = mutableListOf() - val successfulUpdatedApps: List = _successfulUpdatedApps + val successfulUpdatedApps: List + get() = _successfulUpdatedApps.toList() fun addItemsForUpdate( appsNeedUpdate: List, diff --git a/app/src/main/java/foundation/e/apps/data/install/DownloadProgressTracker.kt b/app/src/main/java/foundation/e/apps/data/install/DownloadProgressTracker.kt index ee9eac82cdd0886543b6268565be9ee4fb7a10c9..c063087f1c1433873904433e3560d8e6eb6e00e4 100644 --- a/app/src/main/java/foundation/e/apps/data/install/DownloadProgressTracker.kt +++ b/app/src/main/java/foundation/e/apps/data/install/DownloadProgressTracker.kt @@ -42,10 +42,10 @@ class DownloadProgressTracker @Inject constructor( application?.let { app -> val appDownload = appManager.getDownloadList() .singleOrNull { it.id.contentEquals(app._id) && it.packageName.contentEquals(app.package_name) } - ?: return 0 + ?: return -1 return calculateProgress(appDownload, progress) } - return 0 + return -1 } suspend fun calculateProgress( diff --git a/app/src/main/java/foundation/e/apps/data/install/core/AppInstallationFacade.kt b/app/src/main/java/foundation/e/apps/data/install/core/AppInstallationFacade.kt index 22b5de1d65402f9a9258d1f86040be08fd0091c6..078fb230542a4d024aed8ce0db4a7d95983b4259 100644 --- a/app/src/main/java/foundation/e/apps/data/install/core/AppInstallationFacade.kt +++ b/app/src/main/java/foundation/e/apps/data/install/core/AppInstallationFacade.kt @@ -46,7 +46,8 @@ class AppInstallationFacade @Inject constructor( application: Application, isAnUpdate: Boolean = false ): Boolean { - val appInstall = installationRequest.create(application) + val isUpdate = isAnUpdate || application.status == Status.UPDATABLE + val appInstall = installationRequest.create(application, isUpdateRequest = isUpdate) if (application.source == Source.PLAY_STORE) { val libs = application.dependentLibraries.ifEmpty { @@ -66,10 +67,6 @@ class AppInstallationFacade @Inject constructor( appInstall.sharedLibs = libs } - val isUpdate = isAnUpdate || - application.status == Status.UPDATABLE || - appManager.isAppInstalled(appInstall) - return enqueueAppForInstallation(appInstall, isUpdate, application.isSystemApp) } 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..00d7665ed0deb420dfc95e1c3262e79cc7084cb3 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 @@ -18,13 +18,10 @@ package foundation.e.apps.data.install.core -import android.content.Context -import dagger.hilt.android.qualifiers.ApplicationContext import foundation.e.apps.R import foundation.e.apps.data.application.AppManager import foundation.e.apps.data.event.AppEvent import foundation.e.apps.data.install.core.helper.PreEnqueueChecker -import foundation.e.apps.data.install.workmanager.InstallWorkManager import foundation.e.apps.data.install.wrapper.AppEventDispatcher import foundation.e.apps.data.installation.model.AppInstall import foundation.e.apps.data.preference.PlayStoreAuthStore @@ -34,7 +31,6 @@ import kotlinx.coroutines.CancellationException import timber.log.Timber import javax.inject.Inject class InstallationEnqueuer @Inject constructor( - @ApplicationContext private val context: Context, private val preEnqueueChecker: PreEnqueueChecker, private val appManager: AppManager, private val sessionRepository: SessionRepository, @@ -55,11 +51,7 @@ class InstallationEnqueuer @Inject constructor( canEnqueue(appInstall, isAnUpdate) -> { appManager.updateAwaiting(appInstall) - // Enqueueing installation work is managed by InstallOrchestrator#observeDownloads(). - // This method only handles update work. - if (isAnUpdate) { - enqueueUpdate(appInstall) - } + // InstallOrchestrator owns WorkManager dispatch for queued installs and updates. true } @@ -101,12 +93,6 @@ class InstallationEnqueuer @Inject constructor( } } - private fun enqueueUpdate(appInstall: AppInstall) { - val uniqueWorkName = InstallWorkManager.getUniqueWorkName(appInstall.packageName) - InstallWorkManager.enqueueWork(context, appInstall, true) - Timber.d("UPDATE: Successfully enqueued unique work: $uniqueWorkName") - } - suspend fun canEnqueue(appInstall: AppInstall, isAnUpdate: Boolean = false): Boolean { return preEnqueueChecker.canEnqueue(appInstall, isAnUpdate) } diff --git a/app/src/main/java/foundation/e/apps/data/install/core/InstallationRequest.kt b/app/src/main/java/foundation/e/apps/data/install/core/InstallationRequest.kt index ca0d0044baaec73976d1b6de6aede9c1e0bb4028..16e4f67db2c35dd57df8a318f16a58bb02505e51 100644 --- a/app/src/main/java/foundation/e/apps/data/install/core/InstallationRequest.kt +++ b/app/src/main/java/foundation/e/apps/data/install/core/InstallationRequest.kt @@ -26,7 +26,7 @@ import foundation.e.apps.data.installation.model.AppInstall import foundation.e.apps.data.installation.model.InstallationType import javax.inject.Inject class InstallationRequest @Inject constructor() { - fun create(application: Application): AppInstall { + fun create(application: Application, isUpdateRequest: Boolean = false): AppInstall { val appInstall = AppInstall( application._id, application.source.toInstallationSource(), @@ -41,7 +41,8 @@ class InstallationRequest @Inject constructor() { application.latest_version_code, application.offer_type, application.isFree, - application.originalSize + application.originalSize, + isUpdateRequest = isUpdateRequest ).also { it.contentRating = application.contentRating } diff --git a/app/src/main/java/foundation/e/apps/data/install/core/helper/InstallationCompletionHandler.kt b/app/src/main/java/foundation/e/apps/data/install/core/helper/InstallationCompletionHandler.kt index 6449c1f4507ac14f8407212d6a14565068b1319d..2356b974f1d9e51205b09e5b6ce76a1c303e2be9 100644 --- a/app/src/main/java/foundation/e/apps/data/install/core/helper/InstallationCompletionHandler.kt +++ b/app/src/main/java/foundation/e/apps/data/install/core/helper/InstallationCompletionHandler.kt @@ -46,27 +46,26 @@ class InstallationCompletionHandler @Inject constructor( } override suspend fun onInstallFinished(appInstall: AppInstall?, isUpdateWork: Boolean) { - if (!isUpdateWork) { + if (!isUpdateWork || appInstall?.isUpdateRequest != true) { return } - appInstall?.let { - val packageStatus = appManager.getInstallationStatus(appInstall) + val packageStatus = appManager.getInstallationStatus(appInstall) - if (packageStatus == Status.INSTALLED) { - UpdatesDao.addSuccessfullyUpdatedApp(it) - } + if (packageStatus == Status.INSTALLED) { + UpdatesDao.addSuccessfullyUpdatedApp(appInstall) + } - if (isUpdateCompleted()) { - showNotificationOnUpdateEnded() - UpdatesDao.clearSuccessfullyUpdatedApps() - } + if (isUpdateCompleted()) { + showNotificationOnUpdateEnded() + UpdatesDao.clearSuccessfullyUpdatedApps() } } private suspend fun isUpdateCompleted(): Boolean { val downloadListWithoutAnyIssue = appInstallRepository.getDownloadList().filter { - !listOf(Status.INSTALLATION_ISSUE, Status.PURCHASE_NEEDED).contains(it.status) + it.isUpdateRequest && + !listOf(Status.INSTALLATION_ISSUE, Status.PURCHASE_NEEDED).contains(it.status) } return UpdatesDao.successfulUpdatedApps.isNotEmpty() && downloadListWithoutAnyIssue.isEmpty() diff --git a/app/src/main/java/foundation/e/apps/data/install/updates/UpdatesWorker.kt b/app/src/main/java/foundation/e/apps/data/install/updates/UpdatesWorker.kt index 36c0dcab6162713a502508921f011be75941e64e..af017400caa868c79ed490357d205cab615ad2f7 100644 --- a/app/src/main/java/foundation/e/apps/data/install/updates/UpdatesWorker.kt +++ b/app/src/main/java/foundation/e/apps/data/install/updates/UpdatesWorker.kt @@ -214,7 +214,8 @@ class UpdatesWorker @AssistedInject constructor( val authData = playStoreAuthManager.getValidatedAuthData() val isNotLoggedIntoPersonalAccount = !authData.isValidData() || authData.data?.isAnonymous == true - for (fusedApp in appsNeededToUpdate) { + val appsToUpdate = appsNeededToUpdate.toList() + for (fusedApp in appsToUpdate) { val shouldSkip = (!fusedApp.isFree && isNotLoggedIntoPersonalAccount) if (shouldSkip.or(isStopped)) { // respect the stop signal as well response.add(Pair(fusedApp, 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 index 8cde1a8e348a8cf1eafca07c900aa981170abd2b..405762dc3dab20c5c866e15a14628f9d400a84a8 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 @@ -19,7 +19,6 @@ 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 @@ -30,11 +29,16 @@ import foundation.e.apps.data.installation.repository.AppInstallRepository import foundation.e.apps.domain.model.install.Status import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.runningFold import kotlinx.coroutines.launch import timber.log.Timber +import java.util.concurrent.atomic.AtomicBoolean import javax.inject.Inject class InstallOrchestrator @Inject constructor( @@ -43,31 +47,71 @@ class InstallOrchestrator @Inject constructor( private val appManager: AppManager, private val appInstallRepository: AppInstallRepository ) { + private val isInitialized = AtomicBoolean(false) + private val reevaluationRequests = Channel(Channel.CONFLATED) fun init() { + if (!isInitialized.compareAndSet(false, true)) return + scope.launch { runCatching { cancelFailedDownloads() }.onFailure { throwable -> handleFailure(throwable, "Failed to reconcile startup downloads") } + processQueueReevaluationRequests() observeDownloads() + observeInstallWork() } } private fun observeDownloads() { - appInstallRepository.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 -> - handleFailure(throwable, "Failed to enqueue download worker") - } + appInstallRepository.getDownloads().onEach { + requestQueueReevaluation() }.launchIn(scope) } + private fun observeInstallWork() { + WorkManager.getInstance(context) + .getWorkInfosByTagFlow(InstallWorkManager.INSTALL_WORK_NAME) + .map { workInfos -> workInfos.any { !it.state.isFinished } } + .distinctUntilChanged() + .runningFold(false to false) { previous, isActive -> previous.second to isActive } + .onEach { (wasActive, isActive) -> + if (!wasActive || isActive) return@onEach + requestQueueReevaluation() + }.launchIn(scope) + } + + private fun processQueueReevaluationRequests() { + scope.launch { + while (true) { + reevaluationRequests.receive() + reevaluateQueuedDownloads() + } + } + } + + private fun requestQueueReevaluation() { + reevaluationRequests.trySend(Unit) + } + + private suspend fun reevaluateQueuedDownloads() { + runCatching { + val downloads = appInstallRepository.getDownloadList() + val hasActiveDownloadOrInstall = + downloads.any { it.status == Status.DOWNLOADING || it.status == Status.INSTALLING } + val hasActiveInstallWork = hasActiveInstallWork() + + if (!hasActiveDownloadOrInstall && !hasActiveInstallWork) { + downloads.find { it.status == Status.AWAITING } + ?.let { queuedDownload -> trigger(queuedDownload) } + } + }.onFailure { throwable -> + handleFailure(throwable, "Failed to enqueue download worker") + } + } + private fun handleFailure(throwable: Throwable, errorMessage: String) { when (throwable) { is CancellationException -> throw throwable @@ -80,7 +124,7 @@ class InstallOrchestrator @Inject constructor( val uniqueWorkName = InstallWorkManager.getUniqueWorkName(download.packageName) runCatching { - val operation = InstallWorkManager.enqueueWork(context, download, false) + val operation = InstallWorkManager.enqueueWork(context, download, download.isUpdateRequest) operation.await() Timber.d("INSTALL: Successfully enqueued unique work for ${download.name}: $uniqueWorkName") }.onFailure { throwable -> @@ -92,16 +136,19 @@ class InstallOrchestrator @Inject constructor( } } + private suspend fun hasActiveInstallWork(): Boolean { + val workInfos = WorkManager.getInstance(context) + .getWorkInfosByTagFlow(InstallWorkManager.INSTALL_WORK_NAME) + .firstOrNull() + .orEmpty() + + return workInfos.any { !it.state.isFinished } + } + private suspend fun cancelFailedDownloads() { val workManager = WorkManager.getInstance(context) val apps = appInstallRepository.getDownloads().firstOrNull().orEmpty() - val activeWorkStates = setOf( - WorkInfo.State.ENQUEUED, - WorkInfo.State.RUNNING, - WorkInfo.State.BLOCKED - ) - apps.filter { app -> app.status in listOf( Status.DOWNLOADING, @@ -111,7 +158,7 @@ class InstallOrchestrator @Inject constructor( }.forEach { app -> runCatching { val workInfos = workManager.getWorkInfosByTagFlow(app.id).firstOrNull().orEmpty() - val hasActiveWork = workInfos.any { info -> info.state in activeWorkStates } + val hasActiveWork = workInfos.any { info -> !info.state.isFinished } val hasActiveSession = app.status == Status.INSTALLING && hasActiveInstallSession(app.packageName) diff --git a/app/src/main/java/foundation/e/apps/ui/applicationlist/ApplicationListRVAdapter.kt b/app/src/main/java/foundation/e/apps/ui/applicationlist/ApplicationListRVAdapter.kt index 4ac4c70608ef9b953693afbb19cc9b775998f0ba..ddae3c1a8120623d7e2f57719cccfdcc29e0ba64 100644 --- a/app/src/main/java/foundation/e/apps/ui/applicationlist/ApplicationListRVAdapter.kt +++ b/app/src/main/java/foundation/e/apps/ui/applicationlist/ApplicationListRVAdapter.kt @@ -144,17 +144,9 @@ class ApplicationListRVAdapter( updateRating(searchApp) updateSourceTag(searchApp) setAppIcon(searchApp, shimmerDrawable) - removeIsPurchasedObserver(holder) setInstallButtonDimensions(view) - - if (appInfoFetchViewModel.isAppInBlockedList(searchApp)) { - setupShowMoreButton() - } else { - mainActivityViewModel.verifyUiFilter(searchApp) { - setupInstallButton(searchApp, view, holder) - } - } + bindInstallControls(holder, searchApp) } } @@ -169,6 +161,20 @@ class ApplicationListRVAdapter( } } + fun bindInstallControls(holder: ViewHolder, searchApp: Application = holder.app) { + holder.app = searchApp + removeIsPurchasedObserver(holder) + with(holder.binding) { + if (appInfoFetchViewModel.isAppInBlockedList(searchApp)) { + setupShowMoreButton() + } else { + mainActivityViewModel.verifyUiFilter(searchApp) { + setupInstallButton(searchApp, holder.itemView, holder) + } + } + } + } + companion object { private const val SHIMMER_DURATION_MS = 500L private const val SHIMMER_BASE_ALPHA = 0.7f 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..8951d17267a1329477a3af0bb3d64f7c475793d6 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 @@ -440,6 +440,7 @@ class UpdatesFragment : TimeoutFragment(R.layout.fragment_updates), ApplicationI val progress = appProgressViewModel.calculateProgress(fusedApp, downloadProgress) if (progress == -1) { + restoreInstallButton(adapter, recyclerView, index, fusedApp) return@forEachIndexed } @@ -452,6 +453,26 @@ class UpdatesFragment : TimeoutFragment(R.layout.fragment_updates), ApplicationI } } + private fun restoreInstallButton( + adapter: ApplicationListRVAdapter, + recyclerView: RecyclerView, + index: Int, + fusedApp: Application + ) { + val restoredApp = fusedApp.copy() + mainActivityViewModel.updateStatusOfFusedApps( + applicationList = listOf(restoredApp), + appInstallList = emptyList() + ) + displayedUpdates.find { it._id == restoredApp._id }?.status = restoredApp.status + updateButtonAvailability() + + val viewHolder = recyclerView.findViewHolderForAdapterPosition(index) + as? ApplicationListRVAdapter.ViewHolder + ?: return + adapter.bindInstallControls(viewHolder, restoredApp) + } + override fun onDestroyView() { resetViewState() updatesListAdapter = null diff --git a/app/src/test/java/foundation/e/apps/data/application/UpdatesDaoTest.kt b/app/src/test/java/foundation/e/apps/data/application/UpdatesDaoTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..0b4cfcc46acfce093d6d875f4658390a3513eacf --- /dev/null +++ b/app/src/test/java/foundation/e/apps/data/application/UpdatesDaoTest.kt @@ -0,0 +1,108 @@ +/* + * 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.application + +import com.google.common.truth.Truth.assertThat +import foundation.e.apps.data.application.data.Application +import foundation.e.apps.data.installation.model.AppInstall +import org.junit.After +import org.junit.Before +import org.junit.Test + +class UpdatesDaoTest { + + @Before + fun setUp() { + resetDao() + } + + @After + fun tearDown() { + resetDao() + } + + @Test + fun appsAwaitingForUpdate_returnsSnapshot() { + val firstApp = Application(name = "App1", package_name = "app.one") + val secondApp = Application(name = "App2", package_name = "app.two") + + UpdatesDao.addItemsForUpdate(listOf(firstApp, secondApp)) + + val snapshot = UpdatesDao.appsAwaitingForUpdate + + UpdatesDao.removeUpdateIfExists(secondApp.package_name) + + assertThat(snapshot).containsExactly(firstApp, secondApp).inOrder() + } + + @Test + fun successfulUpdatedApps_returnsSnapshot() { + val firstInstall = AppInstall(id = "1", packageName = "app.one") + val secondInstall = AppInstall(id = "2", packageName = "app.two") + + UpdatesDao.addSuccessfullyUpdatedApp(firstInstall) + UpdatesDao.addSuccessfullyUpdatedApp(secondInstall) + + val snapshot = UpdatesDao.successfulUpdatedApps + + UpdatesDao.clearSuccessfullyUpdatedApps() + + assertThat(snapshot).containsExactly(firstInstall, secondInstall).inOrder() + } + + @Test + fun appsAwaitingForUpdate_snapshotIsSafeToIterateWhileDaoIsModified() { + val firstApp = Application(name = "App1", package_name = "app.one") + val secondApp = Application(name = "App2", package_name = "app.two") + val thirdApp = Application(name = "App3", package_name = "app.three") + + UpdatesDao.addItemsForUpdate(listOf(firstApp, secondApp)) + + val snapshot = UpdatesDao.appsAwaitingForUpdate + + UpdatesDao.addItemsForUpdate(listOf(thirdApp)) + UpdatesDao.removeUpdateIfExists(firstApp.package_name) + + val items = snapshot.map { it.package_name } + assertThat(items).containsExactly("app.one", "app.two").inOrder() + } + + @Test + fun successfulUpdatedApps_snapshotIsSafeToIterateWhileDaoIsModified() { + val firstInstall = AppInstall(id = "1", packageName = "app.one") + val secondInstall = AppInstall(id = "2", packageName = "app.two") + val thirdInstall = AppInstall(id = "3", packageName = "app.three") + + UpdatesDao.addSuccessfullyUpdatedApp(firstInstall) + UpdatesDao.addSuccessfullyUpdatedApp(secondInstall) + + val snapshot = UpdatesDao.successfulUpdatedApps + + UpdatesDao.addSuccessfullyUpdatedApp(thirdInstall) + UpdatesDao.clearSuccessfullyUpdatedApps() + + val items = snapshot.map { it.packageName } + assertThat(items).containsExactly("app.one", "app.two").inOrder() + } + + private fun resetDao() { + UpdatesDao.addItemsForUpdate(emptyList()) + UpdatesDao.clearSuccessfullyUpdatedApps() + } +} diff --git a/app/src/test/java/foundation/e/apps/data/database/install/AppInstallDatabaseTest.kt b/app/src/test/java/foundation/e/apps/data/database/install/AppInstallDatabaseTest.kt index 05f309162a7abfa397e07314c001967f09f03ba6..1171517ddcde7ab037b390f1d0b9647b800d59bd 100644 --- a/app/src/test/java/foundation/e/apps/data/database/install/AppInstallDatabaseTest.kt +++ b/app/src/test/java/foundation/e/apps/data/database/install/AppInstallDatabaseTest.kt @@ -51,7 +51,7 @@ class AppInstallDatabaseTest { context, AppInstallDatabase::class.java, legacyDatabaseName - ).addMigrations(AppInstallDatabase.migration6To7) + ).addMigrations(AppInstallDatabase.migration6To7, AppInstallDatabase.migration7To8) .allowMainThreadQueries() .build() @@ -88,6 +88,97 @@ class AppInstallDatabaseTest { } } + @Test + fun migration7To8_opensVersion7DatabaseAndAddsIsUpdateRequestColumn() { + val legacyDatabaseName = trackDatabase("app_install_test_${System.nanoTime()}") + createVersion7Database(legacyDatabaseName) + + val migratedDatabase = Room.databaseBuilder( + context, + AppInstallDatabase::class.java, + legacyDatabaseName + ).addMigrations(AppInstallDatabase.migration7To8) + .allowMainThreadQueries() + .build() + + try { + migratedDatabase.openHelper.writableDatabase + .query("PRAGMA table_info(`FusedDownload`)") + .use { cursor -> + var foundIsUpdateRequestColumn = false + while (cursor.moveToNext()) { + if (cursor.getString(cursor.getColumnIndexOrThrow("name")) != "isUpdateRequest") { + continue + } + + foundIsUpdateRequestColumn = true + assertThat(cursor.getString(cursor.getColumnIndexOrThrow("type"))) + .isEqualTo("INTEGER") + assertThat(cursor.getInt(cursor.getColumnIndexOrThrow("notnull"))) + .isEqualTo(1) + assertThat(cursor.getString(cursor.getColumnIndexOrThrow("dflt_value"))) + .isEqualTo("0") + } + + assertThat(foundIsUpdateRequestColumn).isTrue() + } + } finally { + migratedDatabase.close() + } + } + + @Test + fun migration7To8_keepsExistingNonUpdateRowsAsNotUpdateRequests() { + val legacyDatabaseName = trackDatabase("app_install_test_${System.nanoTime()}") + createVersion7Database(legacyDatabaseName, orgStatus = "AWAITING") + + val migratedDatabase = Room.databaseBuilder( + context, + AppInstallDatabase::class.java, + legacyDatabaseName + ).addMigrations(AppInstallDatabase.migration7To8) + .allowMainThreadQueries() + .build() + + try { + migratedDatabase.openHelper.writableDatabase + .query("SELECT `isUpdateRequest` FROM `FusedDownload` WHERE `id` = 'legacy-id'") + .use { cursor -> + assertThat(cursor.moveToFirst()).isTrue() + assertThat(cursor.getInt(cursor.getColumnIndexOrThrow("isUpdateRequest"))) + .isEqualTo(0) + } + } finally { + migratedDatabase.close() + } + } + + @Test + fun migration7To8_backfillsQueuedUpdateRowsAsUpdateRequests() { + val legacyDatabaseName = trackDatabase("app_install_test_${System.nanoTime()}") + createVersion7Database(legacyDatabaseName, orgStatus = "UPDATABLE") + + val migratedDatabase = Room.databaseBuilder( + context, + AppInstallDatabase::class.java, + legacyDatabaseName + ).addMigrations(AppInstallDatabase.migration7To8) + .allowMainThreadQueries() + .build() + + try { + migratedDatabase.openHelper.writableDatabase + .query("SELECT `isUpdateRequest` FROM `FusedDownload` WHERE `id` = 'legacy-id'") + .use { cursor -> + assertThat(cursor.moveToFirst()).isTrue() + assertThat(cursor.getInt(cursor.getColumnIndexOrThrow("isUpdateRequest"))) + .isEqualTo(1) + } + } finally { + migratedDatabase.close() + } + } + private fun createVersion6Database(databaseName: String) { context.deleteDatabase(databaseName) val databaseFile = context.getDatabasePath(databaseName) @@ -130,6 +221,50 @@ class AppInstallDatabaseTest { } } + private fun createVersion7Database(databaseName: String, orgStatus: String = "AWAITING") { + context.deleteDatabase(databaseName) + val databaseFile = context.getDatabasePath(databaseName) + databaseFile.parentFile?.mkdirs() + + val database = SQLiteDatabase.openOrCreateDatabase(databaseFile, null) + try { + database.execSQL(VERSION_7_CREATE_TABLE_SQL) + database.execSQL( + """ + INSERT INTO `FusedDownload` ( + `id`, `source`, `status`, `name`, `packageName`, `downloadURLList`, + `downloadIdMap`, `orgStatus`, `type`, `iconImageUrl`, `versionCode`, + `offerType`, `isFree`, `appSize`, `files`, `signature`, `contentRating`, + `sharedLibs` + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """.trimIndent(), + arrayOf( + "legacy-id", + "PLAY_STORE", + "AWAITING", + "Legacy App", + "com.example.legacy", + "[]", + "{}", + orgStatus, + "NATIVE", + "icon.png", + 123L, + 0, + 1, + 2048L, + "[]", + "legacy-signature", + "{}", + "[]" + ) + ) + database.version = 7 + } finally { + database.close() + } + } + private fun trackDatabase(name: String): String { databasesToDelete += name return name @@ -156,5 +291,27 @@ class AppInstallDatabaseTest { "`signature` TEXT NOT NULL, " + "`contentRating` TEXT NOT NULL, " + "PRIMARY KEY(`id`))" + + private const val VERSION_7_CREATE_TABLE_SQL = + "CREATE TABLE IF NOT EXISTS `FusedDownload` (" + + "`id` TEXT NOT NULL, " + + "`source` TEXT NOT NULL, " + + "`status` TEXT NOT NULL, " + + "`name` TEXT NOT NULL, " + + "`packageName` TEXT NOT NULL, " + + "`downloadURLList` TEXT NOT NULL, " + + "`downloadIdMap` TEXT NOT NULL, " + + "`orgStatus` TEXT NOT NULL, " + + "`type` TEXT NOT NULL, " + + "`iconImageUrl` TEXT NOT NULL, " + + "`versionCode` INTEGER NOT NULL, " + + "`offerType` INTEGER NOT NULL, " + + "`isFree` INTEGER NOT NULL, " + + "`appSize` INTEGER NOT NULL, " + + "`files` TEXT NOT NULL, " + + "`signature` TEXT NOT NULL, " + + "`contentRating` TEXT NOT NULL, " + + "`sharedLibs` TEXT NOT NULL, " + + "PRIMARY KEY(`id`))" } } diff --git a/app/src/test/java/foundation/e/apps/data/install/DownloadProgressTrackerTest.kt b/app/src/test/java/foundation/e/apps/data/install/DownloadProgressTrackerTest.kt index 8723e26a93707fe0fedcccb527dae79a94eba4ad..a2426adf2d0ef14602f4e71290849ff04c189fe8 100644 --- a/app/src/test/java/foundation/e/apps/data/install/DownloadProgressTrackerTest.kt +++ b/app/src/test/java/foundation/e/apps/data/install/DownloadProgressTrackerTest.kt @@ -21,6 +21,10 @@ import foundation.e.apps.data.application.AppManager import foundation.e.apps.data.install.download.data.DownloadProgress import foundation.e.apps.data.installation.model.AppInstall import foundation.e.apps.domain.model.install.Status +import foundation.e.apps.data.application.data.Application +import foundation.e.apps.data.enums.Source +import foundation.e.apps.data.enums.Type +import io.mockk.coEvery import io.mockk.mockk import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals @@ -84,4 +88,35 @@ class DownloadProgressTrackerTest { assertEquals(100, percent) } + + @Test + fun calculateProgress_appNotFound_returnsMinusOne() = runTest { + val application = Application( + _id = "missing-id", + package_name = "com.missing.app", + source = Source.PLAY_STORE, + type = Type.NATIVE + ) + coEvery { appManager.getDownloadList() } returns emptyList() + val progress = DownloadProgress( + totalSizeBytes = mutableMapOf(1L to 100L), + bytesDownloadedSoFar = mutableMapOf(1L to 50L), + ) + + val result = downloadProgressTracker.calculateProgress(application, progress) + + assertEquals(-1, result) + } + + @Test + fun calculateProgress_nullApplication_returnsMinusOne() = runTest { + val progress = DownloadProgress( + totalSizeBytes = mutableMapOf(1L to 100L), + bytesDownloadedSoFar = mutableMapOf(1L to 50L), + ) + + val result = downloadProgressTracker.calculateProgress(null, progress) + + assertEquals(-1, result) + } } diff --git a/app/src/test/java/foundation/e/apps/data/install/updates/UpdatesWorkerTest.kt b/app/src/test/java/foundation/e/apps/data/install/updates/UpdatesWorkerTest.kt index 5826fe3cfa99455018c3a712cc3e931b6d73fe01..2afb16baa7dc868a1c420a9a4a72fd4e25f6fa1a 100644 --- a/app/src/test/java/foundation/e/apps/data/install/updates/UpdatesWorkerTest.kt +++ b/app/src/test/java/foundation/e/apps/data/install/updates/UpdatesWorkerTest.kt @@ -59,6 +59,7 @@ import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Test import org.junit.runner.RunWith +import org.mockito.kotlin.doAnswer import org.mockito.kotlin.any import org.mockito.kotlin.doReturn import org.mockito.kotlin.eq @@ -834,6 +835,48 @@ class UpdatesWorkerTest { verify(appInstallationFacade, times(1)).initAppInstall(paidApp, true) } + @Test + fun startUpdateProcess_usesSnapshotWhenInstallMutatesInputList() = runTest { + val workerContext = mock() + val params = mock() + val updatesManagerRepository = mock() + val appLoungeDataStore = createDataStore() + val authData = AuthData(email = "user@example.com", isAnonymous = false) + val playStoreAuthManager = createPlayStoreAuthManager(ResultSupreme.Success(authData)) + val appInstallationFacade = mock() + val blockedAppRepository = mock() + val systemAppsUpdatesRepository = mock() + + val worker = createWorker( + workerContext, + params, + updatesManagerRepository, + appLoungeDataStore, + playStoreAuthManager, + appInstallationFacade, + blockedAppRepository, + systemAppsUpdatesRepository + ) + + val firstApp = Application(name = "App1", package_name = "app.one") + val secondApp = Application(name = "App2", package_name = "app.two") + val appsNeededToUpdate = mutableListOf(firstApp, secondApp) + + doAnswer { + appsNeededToUpdate.remove(secondApp) + true + }.whenever(appInstallationFacade).initAppInstall(firstApp, true) + whenever(appInstallationFacade.initAppInstall(secondApp, true)).thenReturn(false) + + val result = runCatching { worker.startUpdateProcess(appsNeededToUpdate) } + + assertThat(result.exceptionOrNull()).isNull() + assertThat(result.getOrThrow()).containsExactly( + Pair(firstApp, true), + Pair(secondApp, false) + ).inOrder() + } + @Test fun triggerUpdateProcessOnSettings_skipsWhenAutoInstallDisabled() = runTest { val workerContext = mock() 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..712b1ba50d70fa4543897dc6042e09205dbcf669 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 @@ -23,30 +23,33 @@ import android.content.ContextWrapper import android.content.pm.PackageInstaller import android.content.pm.PackageManager import android.os.Build +import androidx.test.core.app.ApplicationProvider import androidx.work.OneTimeWorkRequestBuilder import androidx.work.Operation +import androidx.work.WorkManager 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.application.AppManager -import foundation.e.apps.data.installation.model.AppInstall -import foundation.e.apps.data.installation.repository.AppInstallRepository import foundation.e.apps.data.install.workmanager.InstallOrchestrator import foundation.e.apps.data.install.workmanager.InstallWorkManager +import foundation.e.apps.data.installation.model.AppInstall +import foundation.e.apps.data.installation.repository.AppInstallRepository import foundation.e.apps.domain.model.install.Status 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.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableSharedFlow 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 @@ -58,11 +61,11 @@ 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 +import org.mockito.kotlin.verify as verifyMockito @OptIn(ExperimentalCoroutinesApi::class) @RunWith(RobolectricTestRunner::class) @@ -77,6 +80,7 @@ class InstallOrchestratorTest { private val appManager = mock() private var isInstallWorkManagerMocked = false + private val orchestratorJobs = mutableListOf() @Before fun setup() { @@ -89,6 +93,8 @@ class InstallOrchestratorTest { unmockkObject(InstallWorkManager) isInstallWorkManagerMocked = false } + orchestratorJobs.forEach { it.cancel() } + orchestratorJobs.clear() WorkManagerTestInitHelper.closeWorkDatabase() } @@ -99,10 +105,10 @@ 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, createOrchestratorScope(), appManager, appInstallRepository) installOrchestrator.init() - advanceUntilIdle() + advanceOrchestrator() verifyMockito(appManager).reportInstallationIssue(app) verifyMockito(appManager, never()).updateDownloadStatus(any(), any()) @@ -115,10 +121,10 @@ 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, createOrchestratorScope(), appManager, appInstallRepository) installOrchestrator.init() - advanceUntilIdle() + advanceOrchestrator() verifyMockito(appManager).updateDownloadStatus(app, Status.INSTALLED) verifyMockito(appManager, never()).reportInstallationIssue(any()) @@ -140,10 +146,10 @@ 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, createOrchestratorScope(), appManager, appInstallRepository) installOrchestrator.init() - advanceUntilIdle() + advanceOrchestrator() verifyMockito(appManager, never()).reportInstallationIssue(any()) verifyMockito(appManager, never()).updateDownloadStatus(any(), any()) @@ -155,15 +161,56 @@ class InstallOrchestratorTest { mockInstallWorkManagerSuccess() whenever(appInstallRepository.getDownloads()).thenReturn(flowOf(emptyList()), flowOf(listOf(awaiting))) + whenever(appInstallRepository.getDownloadList()).thenReturn(listOf(awaiting)) - val installOrchestrator = InstallOrchestrator(context, this, appManager, appInstallRepository) + val installOrchestrator = InstallOrchestrator(context, createOrchestratorScope(), appManager, appInstallRepository) installOrchestrator.init() - advanceUntilIdle() + advanceOrchestrator() verify(exactly = 1) { InstallWorkManager.enqueueWork(context, awaiting, false) } } + @Test + fun init_triggersOnlyFirstAwaitingDownloadWhenMultipleAreQueued() = runTest { + val firstAwaiting = createAppInstall(id = "app.awaiting.1", status = Status.AWAITING) + val secondAwaiting = createAppInstall(id = "app.awaiting.2", status = Status.AWAITING) + mockInstallWorkManagerSuccess() + + whenever(appInstallRepository.getDownloads()) + .thenReturn(flowOf(emptyList()), flowOf(listOf(firstAwaiting, secondAwaiting))) + whenever(appInstallRepository.getDownloadList()).thenReturn(listOf(firstAwaiting, secondAwaiting)) + + val installOrchestrator = InstallOrchestrator(context, createOrchestratorScope(), appManager, appInstallRepository) + + installOrchestrator.init() + advanceOrchestrator() + + verify(exactly = 1) { InstallWorkManager.enqueueWork(context, firstAwaiting, false) } + verify(exactly = 0) { InstallWorkManager.enqueueWork(context, secondAwaiting, false) } + } + + @Test + fun init_usesUpdateRequestFlagWhenTriggeringAwaitingDownload() = runTest { + val awaitingUpdate = createAppInstall( + id = "app.awaiting.update", + status = Status.AWAITING, + isUpdateRequest = true + ) + mockInstallWorkManagerSuccess() + + whenever(appInstallRepository.getDownloads()) + .thenReturn(flowOf(emptyList()), flowOf(listOf(awaitingUpdate))) + whenever(appInstallRepository.getDownloadList()).thenReturn(listOf(awaitingUpdate)) + + val installOrchestrator = InstallOrchestrator(context, createOrchestratorScope(), appManager, appInstallRepository) + + installOrchestrator.init() + advanceOrchestrator() + + verify(exactly = 1) { InstallWorkManager.enqueueWork(context, awaitingUpdate, true) } + } + @Test fun init_doesNotTriggerAwaitingWhenThereIsAnActiveDownload() = runTest { val active = createAppInstall(id = "app.active", status = Status.DOWNLOADING) @@ -172,13 +219,95 @@ class InstallOrchestratorTest { whenever(appInstallRepository.getDownloads()) .thenReturn(flowOf(emptyList()), flowOf(listOf(active, awaiting))) + whenever(appInstallRepository.getDownloadList()).thenReturn(listOf(active, awaiting)) + + val installOrchestrator = InstallOrchestrator(context, createOrchestratorScope(), appManager, appInstallRepository) + + installOrchestrator.init() + advanceOrchestrator() + + verify(exactly = 0) { InstallWorkManager.enqueueWork(any(), any(), any()) } + } + + @Test + fun init_doesNotTriggerAwaitingWhenActiveInstallWorkExists() = runTest { + val awaiting = createAppInstall(id = "app.awaiting.active.work", status = Status.AWAITING) + val request = OneTimeWorkRequestBuilder() + .setInitialDelay(1, TimeUnit.HOURS) + .addTag(InstallWorkManager.INSTALL_WORK_NAME) + .build() + WorkManager.getInstance(context).enqueue(request).result.get() + mockInstallWorkManagerSuccess() + + whenever(appInstallRepository.getDownloads()) + .thenReturn(flowOf(emptyList()), flowOf(listOf(awaiting))) + whenever(appInstallRepository.getDownloadList()).thenReturn(listOf(awaiting)) + + val installOrchestrator = InstallOrchestrator(context, createOrchestratorScope(), appManager, appInstallRepository) + + installOrchestrator.init() + advanceOrchestrator() + + verify(exactly = 0) { InstallWorkManager.enqueueWork(any(), any(), any()) } + } + + @Test + fun init_waitsForFailedInstallWorkToBecomeInactiveBeforeTriggeringNextAwaitingDownload() = runTest { + val failed = createAppInstall(id = "app.failed", status = Status.INSTALLATION_ISSUE) + val awaiting = createAppInstall(id = "app.awaiting.after.failure", status = Status.AWAITING) + val activeInstallWork = OneTimeWorkRequestBuilder() + .setInitialDelay(1, TimeUnit.HOURS) + .addTag(InstallWorkManager.INSTALL_WORK_NAME) + .build() + WorkManager.getInstance(context).enqueue(activeInstallWork).result.get() + mockInstallWorkManagerSuccess() + + whenever(appInstallRepository.getDownloads()) + .thenReturn(flowOf(emptyList()), flowOf(listOf(failed, awaiting))) + whenever(appInstallRepository.getDownloadList()).thenReturn(listOf(failed, awaiting)) + + val installOrchestrator = InstallOrchestrator(context, createOrchestratorScope(), appManager, appInstallRepository) + + installOrchestrator.init() + advanceOrchestrator() + + verify(exactly = 0) { InstallWorkManager.enqueueWork(any(), any(), any()) } + + WorkManager.getInstance(context).cancelWorkById(activeInstallWork.id).result.get() + advanceOrchestrator() + + verify(exactly = 1) { InstallWorkManager.enqueueWork(context, awaiting, false) } + } - val installOrchestrator = InstallOrchestrator(context, this, appManager, appInstallRepository) + @Test + fun init_doesNotTriggerAwaitingDownloadTwiceWhenDbAndWorkManagerSignalsOverlap() = runTest { + val failed = createAppInstall(id = "app.failed.overlap", status = Status.INSTALLATION_ISSUE) + val awaiting = createAppInstall(id = "app.awaiting.overlap", status = Status.AWAITING) + val downloads = MutableSharedFlow>() + val activeInstallWork = OneTimeWorkRequestBuilder() + .setInitialDelay(1, TimeUnit.HOURS) + .addTag(InstallWorkManager.INSTALL_WORK_NAME) + .build() + WorkManager.getInstance(context).enqueue(activeInstallWork).result.get() + mockInstallWorkManagerDelayedEnqueue() + + whenever(appInstallRepository.getDownloads()).thenReturn(flowOf(emptyList()), downloads) + whenever(appInstallRepository.getDownloadList()).thenReturn(listOf(failed, awaiting)) + + val installOrchestrator = InstallOrchestrator(context, createOrchestratorScope(), appManager, appInstallRepository) installOrchestrator.init() - advanceUntilIdle() + advanceOrchestrator() + downloads.emit(listOf(failed, awaiting)) + advanceOrchestrator() verify(exactly = 0) { InstallWorkManager.enqueueWork(any(), any(), any()) } + + WorkManager.getInstance(context).cancelWorkById(activeInstallWork.id).result.get() + downloads.emit(listOf(failed, awaiting)) + advanceOrchestrator() + + verify(exactly = 1) { InstallWorkManager.enqueueWork(context, awaiting, false) } } @Test @@ -187,11 +316,12 @@ class InstallOrchestratorTest { mockInstallWorkManagerFailure() whenever(appInstallRepository.getDownloads()).thenReturn(flowOf(emptyList()), flowOf(listOf(awaiting))) + whenever(appInstallRepository.getDownloadList()).thenReturn(listOf(awaiting)) - val installOrchestrator = InstallOrchestrator(context, this, appManager, appInstallRepository) + val installOrchestrator = InstallOrchestrator(context, createOrchestratorScope(), appManager, appInstallRepository) installOrchestrator.init() - advanceUntilIdle() + advanceOrchestrator() verifyMockito(appManager, times(1)).reportInstallationIssue(eq(awaiting)) } @@ -202,11 +332,12 @@ class InstallOrchestratorTest { mockInstallWorkManagerCancellation() whenever(appInstallRepository.getDownloads()).thenReturn(flowOf(emptyList()), flowOf(listOf(awaiting))) + whenever(appInstallRepository.getDownloadList()).thenReturn(listOf(awaiting)) - val installOrchestrator = InstallOrchestrator(context, this, appManager, appInstallRepository) + val installOrchestrator = InstallOrchestrator(context, createOrchestratorScope(), appManager, appInstallRepository) installOrchestrator.init() - advanceUntilIdle() + advanceOrchestrator() verifyMockito(appManager, never()).reportInstallationIssue(any()) } @@ -222,10 +353,10 @@ class InstallOrchestratorTest { whenever(appInstallRepository.getDownloads()).thenReturn(flowOf(listOf(app)), emptyFlow()) - val installOrchestrator = InstallOrchestrator(context, this, appManager, appInstallRepository) + val installOrchestrator = InstallOrchestrator(context, createOrchestratorScope(), appManager, appInstallRepository) installOrchestrator.init() - advanceUntilIdle() + advanceOrchestrator() verifyMockito(appManager, never()).reportInstallationIssue(any()) verifyMockito(appManager, never()).updateDownloadStatus(any(), any()) @@ -238,11 +369,12 @@ class InstallOrchestratorTest { mockInstallWorkManagerSuccess() whenever(appInstallRepository.getDownloads()).thenReturn(flowOf(emptyList()), flowOf(listOf(awaiting))) + whenever(appInstallRepository.getDownloadList()).thenReturn(listOf(awaiting)) - val installOrchestrator = InstallOrchestrator(context, this, appManager, appInstallRepository) + val installOrchestrator = InstallOrchestrator(context, createOrchestratorScope(), appManager, appInstallRepository) installOrchestrator.init() - advanceUntilIdle() + advanceOrchestrator() verifyMockito(appManager, never()).reportInstallationIssue(any()) } @@ -255,11 +387,12 @@ class InstallOrchestratorTest { whenever(appInstallRepository.getDownloads()) .thenThrow(RuntimeException("reconcile failed")) .thenReturn(flowOf(listOf(awaiting))) + whenever(appInstallRepository.getDownloadList()).thenReturn(listOf(awaiting)) - val installOrchestrator = InstallOrchestrator(context, this, appManager, appInstallRepository) + val installOrchestrator = InstallOrchestrator(context, createOrchestratorScope(), appManager, appInstallRepository) installOrchestrator.init() - advanceUntilIdle() + advanceOrchestrator() verify(exactly = 1) { InstallWorkManager.enqueueWork(context, awaiting, false) } } @@ -268,14 +401,62 @@ class InstallOrchestratorTest { fun init_stopsAfterCancellationExceptionDuringReconciliation() = runTest { whenever(appInstallRepository.getDownloads()).thenThrow(CancellationException("cancel reconcile")) - val installOrchestrator = InstallOrchestrator(context, this, appManager, appInstallRepository) + val installOrchestrator = InstallOrchestrator(context, createOrchestratorScope(), appManager, appInstallRepository) installOrchestrator.init() - advanceUntilIdle() + advanceOrchestrator() verifyMockito(appInstallRepository, times(1)).getDownloads() } + @Test + fun init_handlesReevaluationErrorGracefully() = runTest { + val awaiting = createAppInstall(id = "app.awaiting.error", status = Status.AWAITING) + mockInstallWorkManagerSuccess() + + whenever(appInstallRepository.getDownloads()) + .thenReturn(flowOf(emptyList()), flowOf(listOf(awaiting))) + whenever(appInstallRepository.getDownloadList()) + .thenThrow(RuntimeException("repository failed")) + .thenReturn(listOf(awaiting)) + + val installOrchestrator = InstallOrchestrator(context, createOrchestratorScope(), appManager, appInstallRepository) + + installOrchestrator.init() + advanceOrchestrator() + + verify(exactly = 0) { InstallWorkManager.enqueueWork(any(), any(), any()) } + } + + @Test + fun init_isIdempotent() = runTest { + val awaiting = createAppInstall(id = "app.awaiting.once", status = Status.AWAITING) + mockInstallWorkManagerSuccess() + + whenever(appInstallRepository.getDownloads()) + .thenReturn(flowOf(emptyList()), flowOf(listOf(awaiting))) + whenever(appInstallRepository.getDownloadList()).thenReturn(listOf(awaiting)) + + val installOrchestrator = InstallOrchestrator(context, createOrchestratorScope(), appManager, appInstallRepository) + + installOrchestrator.init() + installOrchestrator.init() + advanceOrchestrator() + + verify(exactly = 1) { InstallWorkManager.enqueueWork(context, awaiting, false) } + verifyMockito(appInstallRepository, times(2)).getDownloads() + } + + private fun createOrchestratorScope(): CoroutineScope { + val job = SupervisorJob() + orchestratorJobs += job + return CoroutineScope(mainCoroutineRule.testDispatcher + job) + } + + private fun advanceOrchestrator() { + mainCoroutineRule.testDispatcher.scheduler.advanceUntilIdle() + } + private fun mockInstallWorkManagerSuccess() { mockkObject(InstallWorkManager) isInstallWorkManagerMocked = true @@ -283,6 +464,22 @@ class InstallOrchestratorTest { every { InstallWorkManager.enqueueWork(any(), any(), any()) } returns successfulOperation() } + private fun mockInstallWorkManagerDelayedEnqueue() { + mockkObject(InstallWorkManager) + isInstallWorkManagerMocked = true + every { InstallWorkManager.getUniqueWorkName(any()) } answers { callOriginal() } + every { InstallWorkManager.enqueueWork(any(), any(), any()) } answers { + val appInstall = invocation.args[1] as AppInstall + val request = OneTimeWorkRequestBuilder() + .setInitialDelay(1, TimeUnit.HOURS) + .addTag(appInstall.id) + .addTag(InstallWorkManager.INSTALL_WORK_NAME) + .build() + + WorkManager.getInstance(context).enqueue(request) + } + } + private fun mockInstallWorkManagerFailure() { mockkObject(InstallWorkManager) isInstallWorkManagerMocked = true @@ -307,11 +504,13 @@ class InstallOrchestratorTest { id: String = "app.id", packageName: String = "foundation.e.app", status: Status, + isUpdateRequest: Boolean = false, ) = AppInstall( id = id, name = id, packageName = packageName, - status = status + status = status, + isUpdateRequest = isUpdateRequest ) class NoOpWorker( diff --git a/app/src/test/java/foundation/e/apps/installProcessor/AppInstallationFacadeTest.kt b/app/src/test/java/foundation/e/apps/installProcessor/AppInstallationFacadeTest.kt index af538bf6149ebefac657a3813701596ac6852a9e..39d68f19aadd96de3a9a62f1765112ea3a9c11a9 100644 --- a/app/src/test/java/foundation/e/apps/installProcessor/AppInstallationFacadeTest.kt +++ b/app/src/test/java/foundation/e/apps/installProcessor/AppInstallationFacadeTest.kt @@ -79,7 +79,7 @@ class AppInstallationFacadeTest { } @Test - fun initAppInstall_computesUpdateFlagAndDelegates() = runTest { + fun initAppInstall_marksUpdatableAppsAsUpdateRequests() = runTest { val application = Application( _id = "123", source = Source.PLAY_STORE, @@ -90,8 +90,7 @@ class AppInstallationFacadeTest { dependentLibraries = listOf(SharedLib(packageName = "com.example.lib", versionCode = 1L)) ) val appInstall = AppInstall(id = "123", packageName = "com.example.app") - coEvery { installationRequest.create(application) } returns appInstall - coEvery { appManager.isAppInstalled(appInstall) } returns false + coEvery { installationRequest.create(application, isUpdateRequest = true) } returns appInstall coEvery { installationEnqueuer.enqueue( appInstall, @@ -103,10 +102,67 @@ class AppInstallationFacadeTest { val result = appInstallationFacade.initAppInstall(application) assertTrue(result) - coVerify { installationRequest.create(application) } + coVerify { installationRequest.create(application, isUpdateRequest = true) } coVerify { installationEnqueuer.enqueue(appInstall, true, application.isSystemApp) } } + @Test + fun initAppInstall_marksExplicitUpdatesAsUpdateRequests() = runTest { + val application = Application( + _id = "123", + source = Source.PLAY_STORE, + status = Status.INSTALLED, + name = "Example", + package_name = "com.example.app", + type = Type.NATIVE, + dependentLibraries = listOf(SharedLib(packageName = "com.example.lib", versionCode = 1L)) + ) + val appInstall = AppInstall(id = "123", packageName = "com.example.app") + coEvery { installationRequest.create(application, isUpdateRequest = true) } returns appInstall + coEvery { + installationEnqueuer.enqueue( + appInstall, + true, + application.isSystemApp + ) + } returns true + + val result = appInstallationFacade.initAppInstall(application, isAnUpdate = true) + + assertTrue(result) + coVerify { installationRequest.create(application, isUpdateRequest = true) } + coVerify { installationEnqueuer.enqueue(appInstall, true, application.isSystemApp) } + } + + @Test + fun initAppInstall_doesNotMarkInstalledAppsAsUpdateRequestsFromInstallState() = runTest { + val application = Application( + _id = "123", + source = Source.PLAY_STORE, + status = Status.INSTALLED, + name = "Example", + package_name = "com.example.app", + type = Type.NATIVE, + dependentLibraries = listOf(SharedLib(packageName = "com.example.lib", versionCode = 1L)) + ) + val appInstall = AppInstall(id = "123", packageName = "com.example.app") + coEvery { installationRequest.create(application, isUpdateRequest = false) } returns appInstall + coEvery { + installationEnqueuer.enqueue( + appInstall, + false, + application.isSystemApp + ) + } returns true + + val result = appInstallationFacade.initAppInstall(application) + + assertTrue(result) + coVerify { installationRequest.create(application, isUpdateRequest = false) } + coVerify { installationEnqueuer.enqueue(appInstall, false, application.isSystemApp) } + coVerify(exactly = 0) { appManager.isAppInstalled(any()) } + } + @Test fun enqueueAppForInstallation_delegatesResult() = runTest { val appInstall = AppInstall(id = "123", packageName = "com.example.app") diff --git a/app/src/test/java/foundation/e/apps/installProcessor/InstallationCompletionHandlerTest.kt b/app/src/test/java/foundation/e/apps/installProcessor/InstallationCompletionHandlerTest.kt index 0d35d459d5e9b0d7fc08d9b405c56ee74e1428d7..24bdb9d5fcf9cb2e5854ffbddf2b0bed7dbd4bf1 100644 --- a/app/src/test/java/foundation/e/apps/installProcessor/InstallationCompletionHandlerTest.kt +++ b/app/src/test/java/foundation/e/apps/installProcessor/InstallationCompletionHandlerTest.kt @@ -92,9 +92,27 @@ class InstallationCompletionHandlerTest { } @Test - fun onInstallFinished_tracksInstalledUpdates() = runTest { + fun onInstallFinished_ignoresNonUpdateRequestsEvenWhenUpdateWorkIsTrue() = runTest { val appInstall = AppInstall(id = "123", packageName = "com.example.app") - appInstallRepository.addDownload(AppInstall(id = "pending", status = Status.AWAITING, packageName = "com.example.pending")) + + handler.onInstallFinished(appInstall, true) + + verify(exactly = 0) { appInstallationManager.getInstallationStatus(any()) } + assertTrue(UpdatesDao.successfulUpdatedApps.isEmpty()) + verify(exactly = 0) { UpdatesNotifier.showNotification(any(), any(), any()) } + } + + @Test + fun onInstallFinished_tracksInstalledUpdates() = runTest { + val appInstall = AppInstall(id = "123", packageName = "com.example.app", isUpdateRequest = true) + appInstallRepository.addDownload( + AppInstall( + id = "pending", + status = Status.AWAITING, + packageName = "com.example.pending", + isUpdateRequest = true + ) + ) every { appInstallationManager.getInstallationStatus(appInstall) } returns Status.INSTALLED handler.onInstallFinished(appInstall, true) @@ -105,8 +123,10 @@ class InstallationCompletionHandlerTest { @Test fun onInstallFinished_sendsNotificationWhenUpdateBatchCompletes() = runTest { - val appInstall = AppInstall(id = "123", packageName = "com.example.app") - UpdatesDao.addSuccessfullyUpdatedApp(AppInstall(id = "existing", packageName = "com.example.existing")) + val appInstall = AppInstall(id = "123", packageName = "com.example.app", isUpdateRequest = true) + UpdatesDao.addSuccessfullyUpdatedApp( + AppInstall(id = "existing", packageName = "com.example.existing", isUpdateRequest = true) + ) every { appInstallationManager.getInstallationStatus(appInstall) } returns Status.INSTALLED stubUpdateNotificationContext() @@ -118,19 +138,21 @@ class InstallationCompletionHandlerTest { @Test fun onInstallFinished_ignoresIssueAndPurchaseNeededStatusesForCompletion() = runTest { - val appInstall = AppInstall(id = "123", packageName = "com.example.app") + val appInstall = AppInstall(id = "123", packageName = "com.example.app", isUpdateRequest = true) appInstallRepository.addDownload( AppInstall( id = "issue", status = Status.INSTALLATION_ISSUE, - packageName = "com.example.issue" + packageName = "com.example.issue", + isUpdateRequest = true ) ) appInstallRepository.addDownload( AppInstall( id = "purchase", status = Status.PURCHASE_NEEDED, - packageName = "com.example.purchase" + packageName = "com.example.purchase", + isUpdateRequest = true ) ) every { appInstallationManager.getInstallationStatus(appInstall) } returns Status.INSTALLED @@ -141,9 +163,48 @@ class InstallationCompletionHandlerTest { verify { UpdatesNotifier.showNotification(context, "Update", "Updated message") } } + @Test + fun onInstallFinished_doesNothingWhenAppInstallIsNull() = runTest { + handler.onInstallFinished(null, true) + + verify(exactly = 0) { appInstallationManager.getInstallationStatus(any()) } + assertTrue(UpdatesDao.successfulUpdatedApps.isEmpty()) + verify(exactly = 0) { UpdatesNotifier.showNotification(any(), any(), any()) } + } + + @Test + fun onInstallFinished_doesNotTrackNonInstalledStatus() = runTest { + val appInstall = AppInstall(id = "123", packageName = "com.example.app", isUpdateRequest = true) + every { appInstallationManager.getInstallationStatus(appInstall) } returns Status.INSTALLATION_ISSUE + + handler.onInstallFinished(appInstall, true) + + assertTrue(UpdatesDao.successfulUpdatedApps.isEmpty()) + verify(exactly = 0) { UpdatesNotifier.showNotification(any(), any(), any()) } + } + + @Test + fun onInstallFinished_doesNotNotifyWhenNoSuccessfulUpdates() = runTest { + val appInstall = AppInstall(id = "123", packageName = "com.example.app", isUpdateRequest = true) + appInstallRepository.addDownload( + AppInstall( + id = "pending", + status = Status.AWAITING, + packageName = "com.example.pending", + isUpdateRequest = true + ) + ) + every { appInstallationManager.getInstallationStatus(appInstall) } returns Status.INSTALLED + + handler.onInstallFinished(appInstall, true) + + assertTrue(UpdatesDao.successfulUpdatedApps.contains(appInstall)) + verify(exactly = 0) { UpdatesNotifier.showNotification(any(), any(), any()) } + } + @Test fun onInstallFinished_clearsTrackedUpdatesAfterNotification() = runTest { - val appInstall = AppInstall(id = "123", packageName = "com.example.app") + val appInstall = AppInstall(id = "123", packageName = "com.example.app", isUpdateRequest = true) every { appInstallationManager.getInstallationStatus(appInstall) } returns Status.INSTALLED stubUpdateNotificationContext() @@ -152,6 +213,24 @@ class InstallationCompletionHandlerTest { assertTrue(UpdatesDao.successfulUpdatedApps.isEmpty()) } + @Test + fun onInstallFinished_ignoresPendingNormalInstallsForUpdateCompletion() = runTest { + val appInstall = AppInstall(id = "123", packageName = "com.example.app", isUpdateRequest = true) + appInstallRepository.addDownload( + AppInstall( + id = "pending-normal", + status = Status.AWAITING, + packageName = "com.example.pending", + ) + ) + every { appInstallationManager.getInstallationStatus(appInstall) } returns Status.INSTALLED + stubUpdateNotificationContext() + + handler.onInstallFinished(appInstall, true) + + verify { UpdatesNotifier.showNotification(context, "Update", "Updated message") } + } + private suspend fun stubUpdateNotificationContext() { val authData = AuthData(email = "user@example.com", isAnonymous = false).apply { locale = Locale.US 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..90281d1b1d7a14f6b06d9298564f76cb96cbc468 100644 --- a/app/src/test/java/foundation/e/apps/installProcessor/InstallationEnqueuerTest.kt +++ b/app/src/test/java/foundation/e/apps/installProcessor/InstallationEnqueuerTest.kt @@ -19,10 +19,8 @@ package foundation.e.apps.installProcessor import android.content.Context -import androidx.work.Operation import com.aurora.gplayapi.data.models.AuthData import com.aurora.gplayapi.exceptions.InternalException -import com.google.common.util.concurrent.Futures import foundation.e.apps.R import foundation.e.apps.data.application.AppManager import foundation.e.apps.data.application.ApplicationRepository @@ -49,7 +47,6 @@ import foundation.e.apps.domain.preferences.SessionRepository import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every -import io.mockk.justRun import io.mockk.mockk import io.mockk.mockkObject import io.mockk.mockkStatic @@ -115,7 +112,6 @@ class InstallationEnqueuerTest { devicePreconditions ) enqueuer = InstallationEnqueuer( - context, preEnqueueChecker, appManager, sessionRepository, playStoreAuthStore, @@ -190,51 +186,38 @@ class InstallationEnqueuerTest { fun enqueue_warnsAnonymousPaidUsersAndAborts() = runTest { val appInstall = createNativeInstall(isFree = false) - mockkObject(InstallWorkManager) - try { - coEvery { sessionRepository.awaitUser() } returns User.ANONYMOUS - coEvery { - playStoreAuthStore.awaitAuthData() - } returns AuthData(email = "anon@example.com", isAnonymous = true) - coEvery { appManager.addDownload(appInstall) } returns true - coEvery { ageLimiter.allow(appInstall) } returns true - every { context.isNetworkAvailable() } returns true - every { StorageComputer.spaceMissing(appInstall) } returns 0L - justRun { InstallWorkManager.enqueueWork(any(), any(), any()) } + coEvery { sessionRepository.awaitUser() } returns User.ANONYMOUS + coEvery { + playStoreAuthStore.awaitAuthData() + } returns AuthData(email = "anon@example.com", isAnonymous = true) + coEvery { appManager.addDownload(appInstall) } returns true + coEvery { ageLimiter.allow(appInstall) } returns true + every { context.isNetworkAvailable() } returns true + every { StorageComputer.spaceMissing(appInstall) } returns 0L - val result = enqueuer.enqueue(appInstall) + val result = enqueuer.enqueue(appInstall) - assertFalse(result) - assertTrue(appEventDispatcher.events.any { - it is AppEvent.ErrorMessageEvent && it.data == R.string.paid_app_anonymous_message - }) - coVerify(exactly = 0) { appManager.updateAwaiting(appInstall) } - verify(exactly = 0) { InstallWorkManager.enqueueWork(any(), any(), any()) } - } finally { - unmockkObject(InstallWorkManager) - } + assertFalse(result) + assertTrue(appEventDispatcher.events.any { + it is AppEvent.ErrorMessageEvent && it.data == R.string.paid_app_anonymous_message + }) + coVerify(exactly = 0) { appManager.updateAwaiting(appInstall) } } @Test fun enqueue_returnsFalseWhenCanEnqueueFails() = runTest { val appInstall = createPwaInstall() - mockkObject(InstallWorkManager) - try { - coEvery { appManager.addDownload(appInstall) } returns false + coEvery { appManager.addDownload(appInstall) } returns false - val result = enqueuer.enqueue(appInstall, isAnUpdate = true) + val result = enqueuer.enqueue(appInstall, isAnUpdate = true) - assertFalse(result) - coVerify(exactly = 0) { appManager.updateAwaiting(appInstall) } - verify(exactly = 0) { InstallWorkManager.enqueueWork(any(), any(), any()) } - } finally { - unmockkObject(InstallWorkManager) - } + assertFalse(result) + coVerify(exactly = 0) { appManager.updateAwaiting(appInstall) } } @Test - fun enqueue_enqueuesUpdateWorkWhenUpdateAndChecksPass() = runTest { + fun enqueue_marksUpdateAwaitingWhenUpdateAndChecksPass() = runTest { val appInstall = createPwaInstall() mockkObject(InstallWorkManager) @@ -243,14 +226,12 @@ class InstallationEnqueuerTest { coEvery { ageLimiter.allow(appInstall) } returns true every { context.isNetworkAvailable() } returns true every { StorageComputer.spaceMissing(appInstall) } returns 0L - every { InstallWorkManager.getUniqueWorkName(appInstall.packageName) } answers { callOriginal() } - every { InstallWorkManager.enqueueWork(context, appInstall, true) } returns successfulOperation() val result = enqueuer.enqueue(appInstall, isAnUpdate = true) assertTrue(result) coVerify { appManager.updateAwaiting(appInstall) } - verify(exactly = 1) { InstallWorkManager.enqueueWork(context, appInstall, true) } + verify(exactly = 0) { InstallWorkManager.enqueueWork(any(), any(), any()) } } finally { unmockkObject(InstallWorkManager) } @@ -471,12 +452,6 @@ class InstallationEnqueuerTest { coVerify(exactly = 0) { appManager.addDownload(appInstall) } } - private fun successfulOperation(): Operation { - val operation = mockk() - every { operation.result } returns Futures.immediateFuture(Operation.SUCCESS) - return operation - } - private fun createPwaInstall(isFree: Boolean = true) = AppInstall( type = InstallationType.PWA, id = "123", diff --git a/app/src/test/java/foundation/e/apps/installProcessor/InstallationRequestTest.kt b/app/src/test/java/foundation/e/apps/installProcessor/InstallationRequestTest.kt index a660b6ac652026c69857fd71200956a798043ba6..9629d2924c25fdcf49b896d95e1a201b7dadb980 100644 --- a/app/src/test/java/foundation/e/apps/installProcessor/InstallationRequestTest.kt +++ b/app/src/test/java/foundation/e/apps/installProcessor/InstallationRequestTest.kt @@ -55,7 +55,7 @@ class InstallationRequestTest { originalSize = 2048L ) - val appInstall = installationRequest.create(application) + val appInstall = installationRequest.create(application, isUpdateRequest = true) assertEquals("123", appInstall.id) assertEquals(InstallationSource.PLAY_STORE, appInstall.source) @@ -68,6 +68,8 @@ class InstallationRequestTest { assertEquals(1, appInstall.offerType) assertEquals(false, appInstall.isFree) assertEquals(2048L, appInstall.appSize) + assertEquals(Status.AWAITING, appInstall.orgStatus) + assertTrue(appInstall.isUpdateRequest) } @Test diff --git a/app/src/test/java/foundation/e/apps/ui/compose/state/InstallStatusStreamTest.kt b/app/src/test/java/foundation/e/apps/ui/compose/state/InstallStatusStreamTest.kt index 661fbf2f7284c02e2ca5995ea23657b402dc15a7..d6ac2a627d1efcf54b384c4b13d447afbdfb8047 100644 --- a/app/src/test/java/foundation/e/apps/ui/compose/state/InstallStatusStreamTest.kt +++ b/app/src/test/java/foundation/e/apps/ui/compose/state/InstallStatusStreamTest.kt @@ -81,7 +81,10 @@ class InstallStatusStreamTest { listOf(appInfo("com.example.one")), listOf(appInfo("com.example.two")), ) - every { pwaManager.getInstalledPwaUrls() } returns setOf("https://pwa.example") + every { pwaManager.getInstalledPwaUrls() } returnsMany listOf( + setOf("https://pwa.example"), + setOf("https://pwa.example"), + ) val stream = InstallStatusStream(appManager, appLoungePackageManager, pwaManager) val collectedSnapshots = backgroundScope.async { @@ -90,6 +93,8 @@ class InstallStatusStreamTest { .toList() } + runCurrent() + advanceTimeBy(50) runCurrent() advanceTimeBy(50) runCurrent() diff --git a/data/src/main/java/foundation/e/apps/data/installation/di/AppInstallPersistenceModule.kt b/data/src/main/java/foundation/e/apps/data/installation/di/AppInstallPersistenceModule.kt index 06cc79395dd4ecd36890b8af0e0890ae040d95d0..83555c84d638719723980cf06f1fa218a0016410 100644 --- a/data/src/main/java/foundation/e/apps/data/installation/di/AppInstallPersistenceModule.kt +++ b/data/src/main/java/foundation/e/apps/data/installation/di/AppInstallPersistenceModule.kt @@ -39,6 +39,7 @@ object AppInstallPersistenceModule { fun provideDatabaseInstance(@ApplicationContext context: Context): AppInstallDatabase { return Room.databaseBuilder(context, AppInstallDatabase::class.java, DATABASE_NAME) .fallbackToDestructiveMigration() + .addMigrations(AppInstallDatabase.migration6To7, AppInstallDatabase.migration7To8) .build() } diff --git a/data/src/main/java/foundation/e/apps/data/installation/local/AppInstallDatabase.kt b/data/src/main/java/foundation/e/apps/data/installation/local/AppInstallDatabase.kt index 225ccb9ae545d450e1b0d10cbae45496cfb5240e..1740eab74c7f745e5d5b29dfc0adb7b7605ae98d 100644 --- a/data/src/main/java/foundation/e/apps/data/installation/local/AppInstallDatabase.kt +++ b/data/src/main/java/foundation/e/apps/data/installation/local/AppInstallDatabase.kt @@ -25,7 +25,7 @@ import androidx.room.migration.Migration import androidx.sqlite.db.SupportSQLiteDatabase import foundation.e.apps.data.installation.model.AppInstall -@Database(entities = [AppInstall::class], version = 7, exportSchema = false) +@Database(entities = [AppInstall::class], version = 8, exportSchema = false) @TypeConverters(AppInstallConverter::class) abstract class AppInstallDatabase : RoomDatabase() { abstract fun fusedDownloadDao(): AppInstallDAO @@ -38,5 +38,16 @@ abstract class AppInstallDatabase : RoomDatabase() { ) } } + + val migration7To8 = object : Migration(7, 8) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + "ALTER TABLE FusedDownload ADD COLUMN isUpdateRequest INTEGER NOT NULL DEFAULT 0" + ) + db.execSQL( + "UPDATE FusedDownload SET isUpdateRequest = 1 WHERE orgStatus = 'UPDATABLE'" + ) + } + } } } diff --git a/data/src/main/java/foundation/e/apps/data/installation/model/AppInstall.kt b/data/src/main/java/foundation/e/apps/data/installation/model/AppInstall.kt index d08334250a4958c616e7b79b168d45fc8caf7146..1fd753e82c442d188720699dec799961f20bc244 100644 --- a/data/src/main/java/foundation/e/apps/data/installation/model/AppInstall.kt +++ b/data/src/main/java/foundation/e/apps/data/installation/model/AppInstall.kt @@ -44,7 +44,8 @@ data class AppInstall( var files: List = mutableListOf(), var signature: String = String(), var contentRating: ContentRating = ContentRating(), - var sharedLibs: List = emptyList() + var sharedLibs: List = emptyList(), + val isUpdateRequest: Boolean = false ) { @Ignore private val installingStatusList = listOf(