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

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

Merge branch '3683-sync-installation-status' into 'main'

feat: sync install action state in search results

See merge request !677
parents 75740f22 970a3c15
Loading
Loading
Loading
Loading
Loading
+1 −2
Original line number Diff line number Diff line
@@ -226,7 +226,7 @@ class SearchResultListItemTest {
                            showPrivacyScore = true,
                            isPrivacyLoading = true,
                            primaryAction = PrimaryActionUiState(
                                label = "Download",
                                label = "",
                                enabled = true,
                                isInProgress = true,
                                isFilledStyle = true,
@@ -243,7 +243,6 @@ class SearchResultListItemTest {

        composeRule.onNodeWithTag(SearchResultListItemTestTags.PRIMARY_PROGRESS)
            .assertIsDisplayed()
        composeRule.onAllNodesWithText("Download").assertCountEquals(0)
        composeRule.onNodeWithTag(SearchResultListItemTestTags.PRIVACY_PROGRESS)
            .assertIsDisplayed()
        composeRule.onAllNodesWithText("07/10").assertCountEquals(0)
+196 −1
Original line number Diff line number Diff line
@@ -38,6 +38,7 @@ import androidx.paging.PagingData
import androidx.paging.compose.collectAsLazyPagingItems
import androidx.test.ext.junit.runners.AndroidJUnit4
import kotlinx.coroutines.flow.flowOf
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
@@ -47,6 +48,9 @@ import foundation.e.apps.data.application.data.Application
import foundation.e.apps.data.application.data.Ratings
import foundation.e.apps.data.enums.Source
import foundation.e.apps.data.enums.Status
import foundation.e.apps.ui.compose.state.ButtonLabel
import foundation.e.apps.ui.compose.state.InstallButtonAction
import foundation.e.apps.ui.compose.state.InstallButtonState
import foundation.e.apps.ui.compose.theme.AppTheme
import foundation.e.apps.ui.search.v2.SearchTabType
import java.util.Locale
@@ -104,6 +108,7 @@ class SearchResultsContentTest {
                        selectedTab = selectedTab,
                        fossItems = fossItems,
                        pwaItems = pwaItems,
                        playStoreItems = null,
                        searchVersion = 0,
                        getScrollPosition = { null },
                        onScrollPositionChange = { _, _, _ -> },
@@ -111,6 +116,7 @@ class SearchResultsContentTest {
                            selectedTab = tab
                            selectedTabs.add(tab)
                        },
                        installButtonStateProvider = { defaultInstallButtonState() },
                    )
                }
            }
@@ -164,7 +170,14 @@ class SearchResultsContentTest {
                        status = Status.UPDATABLE,
                    ),
                )
            )
            ),
            installButtonStateProvider = { app ->
                when (app.status) {
                    Status.INSTALLED -> InstallButtonState(label = ButtonLabel(resId = R.string.open))
                    Status.UPDATABLE -> InstallButtonState(label = ButtonLabel(resId = R.string.install))
                    else -> defaultInstallButtonState()
                }
            }
        )

        composeRule.onNodeWithText("com.example.rated").assertIsDisplayed()
@@ -211,6 +224,31 @@ class SearchResultsContentTest {
        ).assertIsDisplayed()
    }

    @Test
    fun appendError_showsFooterRetryWithResults() {
        val pagingData = PagingData.from(
            listOf(sampleApp("Loaded App")),
            sourceLoadStates = loadStates(
                refresh = LoadState.NotLoading(endOfPaginationReached = false),
                append = LoadState.Error(RuntimeException("append boom"))
            )
        )

        renderSearchResults(
            tabs = listOf(SearchTabType.OPEN_SOURCE),
            selectedTab = SearchTabType.OPEN_SOURCE,
            fossPagingData = pagingData,
        )

        composeRule.onNodeWithText("Loaded App").assertIsDisplayed()
        composeRule.onNodeWithText(
            composeRule.activity.getString(R.string.search_error)
        ).assertIsDisplayed()
        composeRule.onNodeWithText(
            composeRule.activity.getString(R.string.retry)
        ).assertIsDisplayed()
    }

    @Test
    fun emptyResults_showsPlaceholder() {
        val pagingData = PagingData.empty<Application>(
@@ -229,6 +267,60 @@ class SearchResultsContentTest {
        composeRule.onNodeWithText(noAppsText).assertIsDisplayed()
    }

    @Test
    fun emptyResults_resetOnNewQuery_showsRefreshLoading() {
        val noAppsText = composeRule.activity.getString(R.string.no_apps_found)
        val emptyPagingData = PagingData.empty<Application>(
            sourceLoadStates = loadStates(
                refresh = LoadState.NotLoading(endOfPaginationReached = true)
            )
        )
        val loadingPagingData = PagingData.empty<Application>(
            sourceLoadStates = loadStates(
                refresh = LoadState.Loading
            )
        )
        lateinit var updateQueryState: () -> Unit

        composeRule.setContent {
            val searchVersion = remember { mutableStateOf(0) }
            val pagingData = remember { mutableStateOf(emptyPagingData) }
            updateQueryState = {
                searchVersion.value = 1
                pagingData.value = loadingPagingData
            }
            val fossItems = remember(pagingData.value) {
                flowOf(pagingData.value)
            }.collectAsLazyPagingItems()

            AppTheme(darkTheme = false) {
                Surface(color = MaterialTheme.colorScheme.background) {
                    SearchResultsContent(
                        tabs = listOf(SearchTabType.OPEN_SOURCE),
                        selectedTab = SearchTabType.OPEN_SOURCE,
                        fossItems = fossItems,
                        pwaItems = null,
                        playStoreItems = null,
                        searchVersion = searchVersion.value,
                        getScrollPosition = { null },
                        onScrollPositionChange = { _, _, _ -> },
                        onTabSelect = {},
                        installButtonStateProvider = { defaultInstallButtonState() },
                    )
                }
            }
        }

        composeRule.onNodeWithText(noAppsText).assertIsDisplayed()
        composeRule.runOnIdle {
            updateQueryState()
        }
        composeRule.waitForIdle()
        composeRule.onNodeWithTag(SearchResultsContentTestTags.REFRESH_LOADER)
            .assertIsDisplayed()
        composeRule.onAllNodesWithText(noAppsText).assertCountEquals(0)
    }

    @Test
    fun appendLoading_showsBottomSpinner() {
        val pagingData = PagingData.from(
@@ -250,18 +342,114 @@ class SearchResultsContentTest {
            .assertIsDisplayed()
    }

    @Test
    fun primaryAction_prefers_literal_label_text() {
        renderSearchResults(
            tabs = listOf(SearchTabType.OPEN_SOURCE),
            selectedTab = SearchTabType.OPEN_SOURCE,
            fossPagingData = pagingData(listOf(sampleApp("Paid App"))),
            installButtonStateProvider = {
                InstallButtonState(label = ButtonLabel(text = "$2.99"))
            }
        )

        composeRule.onNodeWithText("$2.99").assertIsDisplayed()
    }

    @Test
    fun primaryAction_uses_progress_percent_when_no_label_text() {
        renderSearchResults(
            tabs = listOf(SearchTabType.OPEN_SOURCE),
            selectedTab = SearchTabType.OPEN_SOURCE,
            fossPagingData = pagingData(listOf(sampleApp("Downloading App"))),
            installButtonStateProvider = {
                InstallButtonState(
                    progressPercentText = "45%",
                    actionIntent = InstallButtonAction.CancelDownload,
                )
            }
        )

        composeRule.onNodeWithText("45%").assertIsDisplayed()
    }

    @Test
    fun primaryAction_uses_resource_label_when_no_text_or_percent() {
        val openLabel = composeRule.activity.getString(R.string.open)

        renderSearchResults(
            tabs = listOf(SearchTabType.OPEN_SOURCE),
            selectedTab = SearchTabType.OPEN_SOURCE,
            fossPagingData = pagingData(listOf(sampleApp("Open App"))),
            installButtonStateProvider = {
                InstallButtonState(label = ButtonLabel(resId = R.string.open))
            }
        )

        composeRule.onNodeWithText(openLabel).assertIsDisplayed()
    }

    @Test
    fun primaryAction_shows_spinner_when_in_progress_and_blank() {
        renderSearchResults(
            tabs = listOf(SearchTabType.OPEN_SOURCE),
            selectedTab = SearchTabType.OPEN_SOURCE,
            fossPagingData = pagingData(listOf(sampleApp("Installing App"))),
            installButtonStateProvider = {
                InstallButtonState(
                    label = ButtonLabel(text = ""),
                    showProgressBar = true,
                )
            }
        )

        composeRule.onNodeWithTag(SearchResultListItemTestTags.PRIMARY_PROGRESS)
            .assertIsDisplayed()
    }

    @Test
    fun primaryAction_click_forwards_action_intent() {
        var capturedAction: InstallButtonAction? = null

        renderSearchResults(
            tabs = listOf(SearchTabType.OPEN_SOURCE),
            selectedTab = SearchTabType.OPEN_SOURCE,
            fossPagingData = pagingData(listOf(sampleApp("Update App"))),
            installButtonStateProvider = {
                InstallButtonState(
                    label = ButtonLabel(text = "Update"),
                    actionIntent = InstallButtonAction.UpdateSelfConfirm,
                )
            },
            onPrimaryActionClick = { _, action -> capturedAction = action }
        )

        composeRule.onNodeWithTag(SearchResultListItemTestTags.PRIMARY_BUTTON)
            .performClick()

        composeRule.runOnIdle {
            assertEquals(InstallButtonAction.UpdateSelfConfirm, capturedAction)
        }
    }

    private fun renderSearchResults(
        tabs: List<SearchTabType>,
        selectedTab: SearchTabType,
        fossPagingData: PagingData<Application>,
        pwaPagingData: PagingData<Application>? = null,
        playStorePagingData: PagingData<Application>? = null,
        searchVersion: Int = 0,
        installButtonStateProvider: (Application) -> InstallButtonState = { defaultInstallButtonState() },
        onPrimaryActionClick: (Application, InstallButtonAction) -> Unit = { _, _ -> },
    ) {
        composeRule.setContent {
            val fossItems = remember { flowOf(fossPagingData) }.collectAsLazyPagingItems()
            val pwaItems = pwaPagingData?.let {
                remember(it) { flowOf(it) }.collectAsLazyPagingItems()
            }
            val playStoreItems = playStorePagingData?.let {
                remember(it) { flowOf(it) }.collectAsLazyPagingItems()
            }

            AppTheme(darkTheme = false) {
                Surface(color = MaterialTheme.colorScheme.background) {
@@ -270,10 +458,13 @@ class SearchResultsContentTest {
                        selectedTab = selectedTab,
                        fossItems = fossItems,
                        pwaItems = pwaItems,
                        playStoreItems = playStoreItems,
                        searchVersion = searchVersion,
                        getScrollPosition = { null },
                        onScrollPositionChange = { _, _, _ -> },
                        onTabSelect = {},
                        onPrimaryActionClick = onPrimaryActionClick,
                        installButtonStateProvider = installButtonStateProvider,
                    )
                }
            }
@@ -294,4 +485,8 @@ class SearchResultsContentTest {
            refresh = LoadState.NotLoading(endOfPaginationReached = false)
        )
    )

    private fun defaultInstallButtonState() = InstallButtonState(
        label = ButtonLabel(resId = R.string.open),
    )
}
+74 −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.ui.compose.components.search

import androidx.activity.ComponentActivity
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.test.ext.junit.runners.AndroidJUnit4
import foundation.e.apps.R
import foundation.e.apps.ui.compose.theme.AppTheme
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class)
class SearchErrorStateTest {
    @get:Rule
    val composeRule = createAndroidComposeRule<ComponentActivity>()

    @Test
    fun fullScreenError_displaysMessageAndRetry() {
        composeRule.setContent {
            AppTheme(darkTheme = false) {
                Surface(color = MaterialTheme.colorScheme.background) {
                    SearchErrorState(onRetry = {}, fullScreen = true)
                }
            }
        }

        composeRule.onNodeWithText(
            composeRule.activity.getString(R.string.search_error)
        ).assertIsDisplayed()
        composeRule.onNodeWithText(
            composeRule.activity.getString(R.string.retry)
        ).assertIsDisplayed()
    }

    @Test
    fun footerError_displaysMessageAndRetry() {
        composeRule.setContent {
            AppTheme(darkTheme = false) {
                Surface(color = MaterialTheme.colorScheme.background) {
                    SearchErrorState(onRetry = {}, fullScreen = false)
                }
            }
        }

        composeRule.onNodeWithText(
            composeRule.activity.getString(R.string.search_error)
        ).assertIsDisplayed()
        composeRule.onNodeWithText(
            composeRule.activity.getString(R.string.retry)
        ).assertIsDisplayed()
    }
}
+7 −0
Original line number Diff line number Diff line
@@ -49,6 +49,8 @@ class Stores @Inject constructor(
        appLoungePreference
    )

    private val searchEligibleSources = storeConfigs.keys

    private val _enabledStoresFlow = MutableStateFlow(provideEnabledStores())
    val enabledStoresFlow: StateFlow<Set<Source>> = _enabledStoresFlow.asStateFlow()

@@ -64,6 +66,11 @@ class Stores @Inject constructor(
            .mapValues { it.value.repository }
    }

    fun getEnabledSearchSources(): List<Source> =
        storeConfigs
            .filter { (source, config) -> source in searchEligibleSources && config.isEnabled() }
            .map { (source, _) -> source }

    fun getStore(source: Source): StoreRepository? = getStores()[source]

    fun enableStore(source: Source) {
+49 −41
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.install

import android.content.Context
@@ -115,52 +133,39 @@ class AppManagerWrapper @Inject constructor(
            val appDownload = getDownloadList()
                .singleOrNull { it.id.contentEquals(app._id) && it.packageName.contentEquals(app.package_name) }
                ?: return 0

            if (!appDownload.id.contentEquals(app._id) || !appDownload.packageName.contentEquals(app.package_name)) {
                return@let
            }

            if (!isProgressValidForApp(application, progress)) {
                return -1
            return calculateProgress(appDownload, progress)
        }

            val downloadingMap = progress.totalSizeBytes.filter { item ->
                appDownload.downloadIdMap.keys.contains(item.key) && item.value > 0
            }

            if (appDownload.downloadIdMap.size > downloadingMap.size) { // All files for download are not ready yet
        return 0
    }

            val totalSizeBytes = downloadingMap.values.sum()
            val downloadedSoFar = progress.bytesDownloadedSoFar.filter { item ->
                appDownload.downloadIdMap.keys.contains(item.key)
            }.values.sum()
            return ((downloadedSoFar / totalSizeBytes.toDouble()) * PERCENTAGE_MULTIPLIER).toInt()
        }
    suspend fun calculateProgress(
        appInstall: AppInstall,
        progress: DownloadProgress
    ): Int {
        val downloadIds = appInstall.downloadIdMap.keys
        if (downloadIds.isEmpty()) {
            return 0
        }

    private suspend fun isProgressValidForApp(
        application: Application,
        downloadProgress: DownloadProgress
    ): Boolean {
        val download = getDownloadList().singleOrNull {
            it.id == application._id && it.packageName == application.package_name
        } ?: return false
        val totalSizeBytes = progress.totalSizeBytes
            .filterKeys { downloadIds.contains(it) }
            .values
            .sum()

        /*
         * We cannot rely on a single downloadId because DownloadProgress aggregates
         * multiple ids and downloadId is overwritten while iterating.
         * Validation instead checks whether any of the app's download ids are present
         * in the progress maps, which makes progress computation resilient to
         * concurrent multi-part downloads.
         */
        val appDownloadIds = download.downloadIdMap.keys
        return appDownloadIds.any { id ->
            downloadProgress.totalSizeBytes.containsKey(id) ||
                downloadProgress.bytesDownloadedSoFar.containsKey(id)
        val downloadedSoFar = progress.bytesDownloadedSoFar
            .filterKeys { downloadIds.contains(it) }
            .values
            .sum()

        val percent = if (totalSizeBytes > 0) {
            ((downloadedSoFar / totalSizeBytes.toDouble()) * PERCENTAGE_MULTIPLIER)
                .toInt()
                .coerceIn(0, PERCENTAGE_MULTIPLIER)
        } else {
            0
        }

        return percent
    }

    fun handleRatingFormat(rating: Double): String? {
@@ -195,7 +200,10 @@ class AppManagerWrapper @Inject constructor(
        return Pair(1, 0)
    }

    fun getDownloadingItemStatus(application: Application?, downloadList: List<AppInstall>): Status? {
    fun getDownloadingItemStatus(
        application: Application?,
        downloadList: List<AppInstall>
    ): Status? {
        application?.let { app ->
            val downloadingItem =
                downloadList.find { it.packageName == app.package_name || it.id == app.package_name }
Loading