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

Commit ea4b695e authored by Fahim M. Choudhury's avatar Fahim M. Choudhury Committed by Fahim M. Choudhury
Browse files

refactor(search): resolve install button's label and action on onResume() callback

To reflect the correct state and label on the install button, fragment updates the PagingData flow on its onResume() callback. So, when an app is uninstalled from system, the search results don't show it as installed any more, instead, the button's label and state are updated.
parent 07166f78
Loading
Loading
Loading
Loading
+5 −0
Original line number Diff line number Diff line
@@ -72,6 +72,11 @@ class SearchFragmentV2 : Fragment(R.layout.fragment_search_v2) {
        setComposeContent(composeView)
    }

    override fun onResume() {
        super.onResume()
        searchViewModel.refreshInstallStatuses()
    }

    private fun setupComposeView(view: View): ComposeView {
        return view.findViewById<ComposeView>(R.id.composeView).apply {
            setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
+8 −2
Original line number Diff line number Diff line
@@ -105,6 +105,7 @@ class SearchViewModelV2 @Inject constructor(
    private val _scrollPositions = MutableStateFlow<Map<SearchTabType, ScrollPosition>>(emptyMap())
    private val statusSnapshot = MutableStateFlow<StatusSnapshot?>(null)
    private val downloadProgress = MutableStateFlow<DownloadProgress?>(null)
    private val installStatusRefreshTick = MutableStateFlow(0L)
    private val _progressPercentByKey = MutableStateFlow<Map<String, Int>>(emptyMap())
    val progressPercentByKey: StateFlow<Map<String, Int>> = _progressPercentByKey.asStateFlow()
    private val _statusByKey = MutableStateFlow<Map<String, Status>>(emptyMap())
@@ -299,12 +300,17 @@ class SearchViewModelV2 @Inject constructor(
        downloadProgress.value = progress
    }

    fun refreshInstallStatuses() {
        installStatusRefreshTick.update { it + 1 }
    }

    private fun Flow<PagingData<Application>>.withStatus(): Flow<PagingData<Application>> =
        combine(
            this,
            statusSnapshot.filterNotNull(),
            downloadProgress
        ) { paging: PagingData<Application>, snapshot: StatusSnapshot, progress: DownloadProgress? ->
            downloadProgress,
            installStatusRefreshTick
        ) { paging, snapshot, progress, _ ->
            paging.map { app -> reconcile(app, snapshot, progress) }
        }

+162 −0
Original line number Diff line number Diff line
@@ -51,6 +51,7 @@ import io.mockk.mockk
import io.mockk.slot
import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
@@ -546,6 +547,167 @@ class SearchViewModelV2Test {
        assertNull(viewModel.getScrollPosition(SearchTabType.OPEN_SOURCE))
    }

    @Test
    fun `refreshInstallStatuses reconciles existing results without new search`() = runTest {
        val app = Application(
            _id = "id-refresh",
            package_name = "com.example.refresh",
            status = Status.UNAVAILABLE,
        )
        every { searchPagingRepository.cleanApkSearch(any()) } returns flowOf(PagingData.from(listOf(app)))

        var reconciledStatus = Status.INSTALLED
        coEvery { installStatusReconciler.reconcile(any(), any(), any()) } answers {
            val reconciledApp = args[0] as Application
            reconciledApp.status = reconciledStatus
            InstallStatusReconciler.Result(reconciledApp)
        }

        viewModel.onSearchSubmitted("apps")
        collectApplications(viewModel.fossPagingFlow.first())
        assertEquals(Status.INSTALLED, viewModel.statusFor(app))

        reconciledStatus = Status.UPDATABLE
        viewModel.refreshInstallStatuses()
        collectApplications(viewModel.fossPagingFlow.first())

        assertEquals(Status.UPDATABLE, viewModel.statusFor(app))
        verify(exactly = 1) { searchPagingRepository.cleanApkSearch(any()) }
    }

    @Test
    fun `refreshInstallStatuses updates uninstalled app to unavailable`() = runTest {
        val app = Application(
            _id = "id-uninstall",
            package_name = "com.example.uninstall",
            status = Status.UPDATABLE,
        )
        every { searchPagingRepository.cleanApkSearch(any()) } returns flowOf(PagingData.from(listOf(app)))

        var reconciledStatus = Status.UPDATABLE
        coEvery { installStatusReconciler.reconcile(any(), any(), any()) } answers {
            val reconciledApp = args[0] as Application
            reconciledApp.status = reconciledStatus
            InstallStatusReconciler.Result(reconciledApp)
        }

        viewModel.onSearchSubmitted("apps")
        collectApplications(viewModel.fossPagingFlow.first())
        assertEquals(Status.UPDATABLE, viewModel.statusFor(app))

        reconciledStatus = Status.UNAVAILABLE
        viewModel.refreshInstallStatuses()
        collectApplications(viewModel.fossPagingFlow.first())

        assertEquals(Status.UNAVAILABLE, viewModel.statusFor(app))
    }

    @Test
    fun `refreshInstallStatuses is safe when snapshot or progress is null`() = runTest {
        every { installStatusStream.stream(any(), any(), any()) } returns emptyFlow()
        buildViewModel()

        viewModel.refreshInstallStatuses()
        viewModel.updateDownloadProgress(null)

        assertTrue(viewModel.statusByKey.value.isEmpty())
        assertTrue(viewModel.progressPercentByKey.value.isEmpty())
    }

    @Test
    fun `refreshInstallStatuses multiple apps updates only affected app`() = runTest {
        val appOne = Application(
            _id = "id-one",
            package_name = "com.example.one",
            status = Status.INSTALLED,
        )
        val appTwo = Application(
            _id = "id-two",
            package_name = "com.example.two",
            status = Status.INSTALLED,
        )
        every { searchPagingRepository.cleanApkSearch(any()) } returns flowOf(
            PagingData.from(listOf(appOne, appTwo))
        )

        val statusByPackage = mutableMapOf(
            appOne.package_name to Status.INSTALLED,
            appTwo.package_name to Status.INSTALLED,
        )
        coEvery { installStatusReconciler.reconcile(any(), any(), any()) } answers {
            val reconciledApp = args[0] as Application
            reconciledApp.status = statusByPackage[reconciledApp.package_name] ?: Status.UNAVAILABLE
            InstallStatusReconciler.Result(reconciledApp)
        }

        viewModel.onSearchSubmitted("apps")
        collectApplications(viewModel.fossPagingFlow.first())
        assertEquals(Status.INSTALLED, viewModel.statusFor(appOne))
        assertEquals(Status.INSTALLED, viewModel.statusFor(appTwo))

        statusByPackage[appOne.package_name] = Status.UNAVAILABLE
        viewModel.refreshInstallStatuses()
        collectApplications(viewModel.fossPagingFlow.first())

        assertEquals(Status.UNAVAILABLE, viewModel.statusFor(appOne))
        assertEquals(Status.INSTALLED, viewModel.statusFor(appTwo))
    }

    @Test
    fun `refresh does not corrupt progressPercentByKey`() = runTest {
        val app = Application(
            _id = "id-progress-refresh",
            package_name = "com.example.progress.refresh",
            status = Status.DOWNLOADING,
        )
        every { searchPagingRepository.cleanApkSearch(any()) } returns flowOf(PagingData.from(listOf(app)))

        var reconciledProgress = 18
        coEvery { installStatusReconciler.reconcile(any(), any(), any()) } answers {
            val reconciledApp = args[0] as Application
            reconciledApp.status = Status.DOWNLOADING
            InstallStatusReconciler.Result(reconciledApp, reconciledProgress)
        }

        viewModel.onSearchSubmitted("apps")
        collectApplications(viewModel.fossPagingFlow.first())
        assertEquals(18, viewModel.progressPercentFor(app))

        reconciledProgress = 67
        viewModel.refreshInstallStatuses()
        collectApplications(viewModel.fossPagingFlow.first())

        assertEquals(67, viewModel.progressPercentFor(app))
    }

    @Test
    fun `refresh does not break pendingInstalls cleanup`() = runTest {
        val app = Application(
            _id = "id-pending-refresh",
            package_name = "com.example.pending.refresh",
            status = Status.UNAVAILABLE,
        )
        every { searchPagingRepository.cleanApkSearch(any()) } returns flowOf(PagingData.from(listOf(app)))

        var reconciledStatus = Status.UNAVAILABLE
        coEvery { installStatusReconciler.reconcile(any(), any(), any()) } answers {
            val reconciledApp = args[0] as Application
            reconciledApp.status = reconciledStatus
            InstallStatusReconciler.Result(reconciledApp)
        }

        viewModel.markPendingInstall(app)
        viewModel.onSearchSubmitted("apps")
        collectApplications(viewModel.fossPagingFlow.first())
        assertTrue(viewModel.pendingInstalls.value.contains(app.package_name))

        reconciledStatus = Status.DOWNLOADING
        viewModel.refreshInstallStatuses()
        collectApplications(viewModel.fossPagingFlow.first())

        assertFalse(viewModel.pendingInstalls.value.contains(app.package_name))
    }

    @Test
    fun `progress percent keys by package name`() = runTest {
        val app = Application(