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

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

feat: sync install action state in search results

Unify search result buttons with live install/download state so users see accurate actions while downloads and installs progress.

Introduce a status stream + reconciler and map that into Compose UI state to keep button rendering and behaviors consistent across sources.
parent f1632493
Loading
Loading
Loading
Loading
+51 −22
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,30 +133,38 @@ 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
            return calculateProgress(appDownload, progress)
        }

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

            val downloadingMap = progress.totalSizeBytes.filter { item ->
                appDownload.downloadIdMap.keys.contains(item.key) && item.value > 0
    suspend fun calculateProgress(
        appInstall: AppInstall,
        progress: DownloadProgress
    ): Int {
        val downloadIds = appInstall.downloadIdMap.keys
        if (downloadIds.isEmpty()) {
            // Download request exists but ids not yet populated; show 0% instead of dropping percent.
            return 0
        }

            if (appDownload.downloadIdMap.size > downloadingMap.size) { // All files for download are not ready yet
        val totalSizeBytes = progress.totalSizeBytes
            .filterKeys { downloadIds.contains(it) }
            .values
            .sum()
        if (totalSizeBytes <= 0) {
            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()
        }
        return 0
        val downloadedSoFar = progress.bytesDownloadedSoFar
            .filterKeys { downloadIds.contains(it) }
            .values
            .sum()

        val percent = ((downloadedSoFar / totalSizeBytes.toDouble()) * 100)
            .toInt()
            .coerceIn(0, 100)
        return percent
    }

    private suspend fun isProgressValidForApp(
@@ -195,7 +221,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 }
+26 −0
Original line number Diff line number Diff line
@@ -114,6 +114,32 @@ class PwaManager @Inject constructor(
        context.startActivity(launchIntent)
    }

    /**
     * Return all installed PWA URLs from PWA Player.
     * Used for periodic status polling in Compose search to mirror legacy status detection.
     */
    fun getInstalledPwaUrls(): Set<String> {
        val installed = mutableSetOf<String>()
        context.contentResolver.query(
            PWA_PLAYER.toUri(),
            arrayOf("url"),
            null,
            null,
            null
        )?.use { cursor ->
            val urlIndex = cursor.getColumnIndex("url")
            if (urlIndex != -1) {
                while (cursor.moveToNext()) {
                    val url = cursor.getString(urlIndex)
                    if (!url.isNullOrBlank()) {
                        installed.add(url)
                    }
                }
            }
        }
        return installed
    }

    suspend fun installPWAApp(appInstall: AppInstall) {
        // Update status
        appInstall.status = Status.DOWNLOADING
+30 −16
Original line number Diff line number Diff line
@@ -18,6 +18,7 @@

package foundation.e.apps.ui.compose.components

import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
@@ -44,6 +45,7 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
@@ -54,6 +56,7 @@ import androidx.compose.ui.unit.dp
import coil.compose.rememberImagePainter
import foundation.e.apps.R
import foundation.e.apps.data.application.data.Application
import foundation.e.apps.ui.compose.state.InstallButtonAction
import foundation.e.apps.ui.compose.theme.AppTheme

@Composable
@@ -271,8 +274,16 @@ private fun PrimaryActionArea(
        // render the primary action button
    }

    val accentColor = MaterialTheme.colorScheme.tertiary

    val labelTextColor = when {
        uiState.isFilledStyle -> MaterialTheme.colorScheme.onPrimary
        else -> accentColor
    }

    val buttonContent: @Composable () -> Unit = {
        if (uiState.isInProgress) {
        val showSpinner = uiState.isInProgress && uiState.label.isBlank()
        if (showSpinner) {
            val indicatorColor =
                if (uiState.isFilledStyle) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onPrimary
            CircularProgressIndicator(
@@ -280,7 +291,7 @@ private fun PrimaryActionArea(
                    .size(16.dp)
                    .testTag(SearchResultListItemTestTags.PRIMARY_PROGRESS),
                strokeWidth = 2.dp,
                color = indicatorColor,
                color = labelTextColor,
            )
        } else {
            val textColor =
@@ -289,21 +300,16 @@ private fun PrimaryActionArea(
                text = uiState.label,
                maxLines = 1,
                overflow = TextOverflow.Clip,
                color = textColor,
                color = labelTextColor,
            )
        }
    }

    Column(horizontalAlignment = Alignment.End) {
        val containerColor = if (uiState.isFilledStyle) {
            MaterialTheme.colorScheme.primary
        } else {
            MaterialTheme.colorScheme.secondaryContainer
        }
        val contentColor = if (uiState.isFilledStyle) {
            MaterialTheme.colorScheme.onPrimary
        } else {
            MaterialTheme.colorScheme.onSecondaryContainer
        val borderColor = when {
            uiState.isFilledStyle -> Color.Transparent
            uiState.enabled -> accentColor
            else -> accentColor.copy(alpha = 0.38f)
        }
        Button(
            onClick = onPrimaryClick,
@@ -313,11 +319,18 @@ private fun PrimaryActionArea(
                .testTag(SearchResultListItemTestTags.PRIMARY_BUTTON),
            shape = RoundedCornerShape(4.dp),
            colors = ButtonDefaults.buttonColors(
                containerColor = containerColor,
                contentColor = contentColor,
                disabledContainerColor = containerColor.copy(alpha = 0.38f),
                disabledContentColor = contentColor.copy(alpha = 0.38f),
                containerColor = when {
                    uiState.isFilledStyle -> accentColor
                    else -> Color.Transparent
                },
                contentColor = labelTextColor,
                disabledContainerColor = when {
                    uiState.isFilledStyle -> accentColor.copy(alpha = 0.12f)
                    else -> Color.Transparent
                },
                disabledContentColor = labelTextColor.copy(alpha = 0.38f),
            ),
            border = BorderStroke(1.dp, borderColor),
            contentPadding = ButtonDefaults.ContentPadding,
        ) {
            buttonContent()
@@ -369,6 +382,7 @@ data class PrimaryActionUiState(
    val isInProgress: Boolean,
    val isFilledStyle: Boolean,
    val showMore: Boolean = false,
    val actionIntent: InstallButtonAction = InstallButtonAction.NoOp,
)

internal object SearchResultListItemTestTags {
+50 −20
Original line number Diff line number Diff line
@@ -39,21 +39,21 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.paging.LoadState
import androidx.paging.compose.LazyPagingItems
import com.aurora.gplayapi.data.models.App
import foundation.e.apps.R
import foundation.e.apps.data.application.data.Application
import foundation.e.apps.data.application.utils.toApplication
import foundation.e.apps.data.enums.Source
import foundation.e.apps.data.enums.Status
import foundation.e.apps.ui.compose.components.search.SearchErrorState
import foundation.e.apps.ui.compose.components.search.SearchResultListItemPlaceholder
import foundation.e.apps.ui.compose.components.search.SearchShimmerList
import foundation.e.apps.ui.compose.state.InstallButtonAction
import foundation.e.apps.ui.compose.state.InstallButtonState
import foundation.e.apps.ui.compose.state.InstallButtonStyle
import foundation.e.apps.ui.search.v2.ScrollPosition
import foundation.e.apps.ui.search.v2.SearchTabType
import kotlinx.coroutines.flow.collectLatest
@@ -66,16 +66,17 @@ fun SearchResultsContent(
    selectedTab: SearchTabType,
    fossItems: LazyPagingItems<Application>?,
    pwaItems: LazyPagingItems<Application>?,
    playStoreItems: LazyPagingItems<Application>?,
    searchVersion: Int,
    getScrollPosition: (SearchTabType) -> ScrollPosition?,
    onScrollPositionChange: (SearchTabType, Int, Int) -> Unit,
    onTabSelect: (SearchTabType) -> Unit,
    modifier: Modifier = Modifier,
    playStoreItems: LazyPagingItems<App>? = null,
    onResultClick: (Application) -> Unit = {},
    onPrimaryActionClick: (Application) -> Unit = {},
    onPrimaryActionClick: (Application, InstallButtonAction) -> Unit = { _, _ -> },
    onShowMoreClick: (Application) -> Unit = {},
    onPrivacyClick: (Application) -> Unit = {},
    installButtonStateProvider: (Application) -> InstallButtonState,
) {
    if (tabs.isEmpty() || selectedTab !in tabs) {
        return
@@ -138,6 +139,7 @@ fun SearchResultsContent(
                        onPrimaryActionClick = onPrimaryActionClick,
                        onShowMoreClick = onShowMoreClick,
                        onPrivacyClick = onPrivacyClick,
                        installButtonStateProvider = installButtonStateProvider,
                        modifier = Modifier.fillMaxSize(),
                    )
                }
@@ -153,6 +155,7 @@ fun SearchResultsContent(
                        onPrimaryActionClick = onPrimaryActionClick,
                        onShowMoreClick = onShowMoreClick,
                        onPrivacyClick = onPrivacyClick,
                        installButtonStateProvider = installButtonStateProvider,
                        modifier = Modifier.fillMaxSize(),
                    )
                }
@@ -167,6 +170,7 @@ fun SearchResultsContent(
                        onPrimaryActionClick = onPrimaryActionClick,
                        onShowMoreClick = onShowMoreClick,
                        onPrivacyClick = onPrivacyClick,
                        installButtonStateProvider = installButtonStateProvider,
                        modifier = Modifier.fillMaxSize(),
                    )
                }
@@ -177,17 +181,17 @@ fun SearchResultsContent(

@Composable
private fun PagingPlayStoreResultList(
    items: LazyPagingItems<App>?,
    items: LazyPagingItems<Application>?,
    searchVersion: Int,
    getScrollPosition: (SearchTabType) -> ScrollPosition?,
    onScrollPositionChange: (SearchTabType, Int, Int) -> Unit,
    onItemClick: (Application) -> Unit,
    onPrimaryActionClick: (Application) -> Unit,
    onPrimaryActionClick: (Application, InstallButtonAction) -> Unit,
    onShowMoreClick: (Application) -> Unit,
    onPrivacyClick: (Application) -> Unit,
    installButtonStateProvider: (Application) -> InstallButtonState,
    modifier: Modifier = Modifier,
) {
    val context = LocalContext.current
    val lazyItems = items ?: return
    val saved = getScrollPosition(SearchTabType.COMMON_APPS)
    val listState = rememberSaveable(
@@ -251,18 +255,25 @@ private fun PagingPlayStoreResultList(
                        count = lazyItems.itemCount,
                        key = { index ->
                            val item = lazyItems.peek(index)
                            item?.packageName.takeIf { !it.isNullOrBlank() }
                                ?: item?.id.toString()
                            item?.package_name.takeIf { !it.isNullOrBlank() }
                                ?: item?._id.toString()
                        },
                    ) { index ->
                        val app = lazyItems[index]
                        if (app != null) {
                            val application = app.toApplication(context)
                        val application = lazyItems[index]
                        if (application != null) {
                            val uiState = application.toSearchResultUiState(
                                installButtonStateProvider(application)
                            )
                            SearchResultListItem(
                                application = application,
                                uiState = application.toSearchResultUiState(),
                                uiState = uiState,
                                onItemClick = onItemClick,
                                onPrimaryActionClick = onPrimaryActionClick,
                                onPrimaryActionClick = {
                                    onPrimaryActionClick(
                                        application,
                                        uiState.primaryAction.actionIntent
                                    )
                                },
                                onShowMoreClick = onShowMoreClick,
                                onPrivacyClick = onPrivacyClick,
                                modifier = Modifier.fillMaxWidth(),
@@ -298,9 +309,10 @@ private fun PagingSearchResultList(
    getScrollPosition: (SearchTabType) -> ScrollPosition?,
    onScrollPositionChange: (SearchTabType, Int, Int) -> Unit,
    onItemClick: (Application) -> Unit,
    onPrimaryActionClick: (Application) -> Unit,
    onPrimaryActionClick: (Application, InstallButtonAction) -> Unit,
    onShowMoreClick: (Application) -> Unit,
    onPrivacyClick: (Application) -> Unit,
    installButtonStateProvider: (Application) -> InstallButtonState,
    modifier: Modifier = Modifier,
) {
    val lazyItems = items ?: return
@@ -371,11 +383,19 @@ private fun PagingSearchResultList(
                    ) { index ->
                        val application = lazyItems[index]
                        if (application != null) {
                            val uiState = application.toSearchResultUiState(
                                installButtonStateProvider(application)
                            )
                            SearchResultListItem(
                                application = application,
                                uiState = application.toSearchResultUiState(),
                                uiState = uiState,
                                onItemClick = onItemClick,
                                onPrimaryActionClick = onPrimaryActionClick,
                                onPrimaryActionClick = {
                                    onPrimaryActionClick(
                                        application,
                                        uiState.primaryAction.actionIntent
                                    )
                                },
                                onShowMoreClick = onShowMoreClick,
                                onPrivacyClick = onPrivacyClick,
                                modifier = Modifier.fillMaxWidth(),
@@ -408,7 +428,7 @@ private fun PagingSearchResultList(
}

@Composable
private fun Application.toSearchResultUiState(): SearchResultListItemState {
private fun Application.toSearchResultUiState(buttonState: InstallButtonState): SearchResultListItemState {
    if (isPlaceHolder) {
        return SearchResultListItemState(
            author = "",
@@ -453,7 +473,17 @@ private fun Application.toSearchResultUiState(): SearchResultListItemState {
        privacyScore = "",
        showPrivacyScore = false, // Privacy scores are disabled on Search per functional spec.
        isPrivacyLoading = false,
        primaryAction = resolvePrimaryActionState(this),
        primaryAction = PrimaryActionUiState(
            label = buttonState.label.text
               ?: buttonState.progressPercentText
                ?: buttonState.label.resId?.let { stringResource(id = it) }
                ?: "",
            enabled = buttonState.enabled,
            isInProgress = buttonState.isInProgress(),
            isFilledStyle = buttonState.style == InstallButtonStyle.AccentFill,
            showMore = false,
            actionIntent = buttonState.actionIntent,
        ),
        iconUrl = iconUrl,
        placeholderResId = null,
        isPlaceholder = false,
+8 −4
Original line number Diff line number Diff line
@@ -41,10 +41,11 @@ import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.repeatOnLifecycle
import androidx.paging.PagingData
import androidx.paging.compose.collectAsLazyPagingItems
import com.aurora.gplayapi.data.models.App
import foundation.e.apps.data.application.data.Application
import foundation.e.apps.ui.compose.components.SearchInitialState
import foundation.e.apps.ui.compose.components.SearchResultsContent
import foundation.e.apps.ui.compose.state.InstallButtonAction
import foundation.e.apps.ui.compose.state.InstallButtonState
import foundation.e.apps.ui.search.v2.ScrollPosition
import foundation.e.apps.ui.search.v2.SearchTabType
import foundation.e.apps.ui.search.v2.SearchUiState
@@ -62,7 +63,7 @@ fun SearchScreen(
    modifier: Modifier = Modifier,
    fossPaging: Flow<PagingData<Application>>? = null,
    pwaPaging: Flow<PagingData<Application>>? = null,
    playStorePaging: Flow<PagingData<App>>? = null,
    playStorePaging: Flow<PagingData<Application>>? = null,
    searchVersion: Int = 0,
    getScrollPosition: (SearchTabType) -> ScrollPosition? = { null },
    onScrollPositionChange: (SearchTabType, Int, Int) -> Unit = { _, _, _ -> },
@@ -70,6 +71,8 @@ fun SearchScreen(
    onPrimaryActionClick: (Application) -> Unit = {},
    onShowMoreClick: (Application) -> Unit = {},
    onPrivacyClick: (Application) -> Unit = {},
    onPrimaryAction: (Application, InstallButtonAction) -> Unit = { _, _ -> },
    installButtonStateProvider: (Application) -> InstallButtonState,
) {
    val focusManager = LocalFocusManager.current
    val keyboardController = LocalSoftwareKeyboardController.current
@@ -146,6 +149,7 @@ fun SearchScreen(
                    selectedTab = uiState.selectedTab!!,
                    fossItems = fossItems,
                    pwaItems = pwaItems,
                    playStoreItems = playStoreItems,
                    searchVersion = searchVersion,
                    getScrollPosition = getScrollPosition,
                    onScrollPositionChange = onScrollPositionChange,
@@ -153,11 +157,11 @@ fun SearchScreen(
                    modifier = Modifier
                        .fillMaxWidth()
                        .padding(top = 8.dp),
                    playStoreItems = playStoreItems,
                    onResultClick = onResultClick,
                    onPrimaryActionClick = onPrimaryActionClick,
                    onPrimaryActionClick = onPrimaryAction,
                    onShowMoreClick = onShowMoreClick,
                    onPrivacyClick = onPrivacyClick,
                    installButtonStateProvider = installButtonStateProvider,
                )
            } else {
                SearchInitialState(
Loading