Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Verified Commit 9f77b328 authored by Fahim M. Choudhury's avatar Fahim M. Choudhury
Browse files

fix(updates): fix ConcurrentModificationException while updating apps

Prevent concurrent modification during update installs and when exposing DAO-backed update lists.
parent c41932f3
Loading
Loading
Loading
Loading
+4 −2
Original line number Diff line number Diff line
@@ -22,12 +22,14 @@ import foundation.e.apps.data.installation.model.AppInstall

object UpdatesDao {
    private val _appsAwaitingForUpdate: MutableList<Application> = mutableListOf()
    val appsAwaitingForUpdate: List<Application> = _appsAwaitingForUpdate
    val appsAwaitingForUpdate: List<Application>
        get() = _appsAwaitingForUpdate.toList()
    var appsAwaitingForUpdateIncludesOtherStores: Boolean = false
        private set

    private val _successfulUpdatedApps = mutableListOf<AppInstall>()
    val successfulUpdatedApps: List<AppInstall> = _successfulUpdatedApps
    val successfulUpdatedApps: List<AppInstall>
        get() = _successfulUpdatedApps.toList()

    fun addItemsForUpdate(
        appsNeedUpdate: List<Application>,
+2 −1
Original line number Diff line number Diff line
@@ -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))
+73 −0
Original line number Diff line number Diff line
/*
 * 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 <https://www.gnu.org/licenses/>.
 *
 */

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()
    }

    private fun resetDao() {
        UpdatesDao.addItemsForUpdate(emptyList())
        UpdatesDao.clearSuccessfullyUpdatedApps()
    }
}
+43 −0
Original line number Diff line number Diff line
@@ -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<Context>()
        val params = mock<WorkerParameters>()
        val updatesManagerRepository = mock<UpdatesManagerRepository>()
        val appLoungeDataStore = createDataStore()
        val authData = AuthData(email = "user@example.com", isAnonymous = false)
        val playStoreAuthManager = createPlayStoreAuthManager(ResultSupreme.Success(authData))
        val appInstallationFacade = mock<AppInstallationFacade>()
        val blockedAppRepository = mock<BlockedAppRepository>()
        val systemAppsUpdatesRepository = mock<SystemAppsUpdatesRepository>()

        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<Context>()