From 7a0be2a2f288255eabfcc4da7bef73461b168c52 Mon Sep 17 00:00:00 2001 From: Fahim Masud Choudhury Date: Mon, 5 Jan 2026 21:25:27 +0600 Subject: [PATCH 01/17] 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. --- .../e/apps/data/install/AppManagerWrapper.kt | 74 +++-- .../e/apps/install/pkg/PwaManager.kt | 26 ++ .../components/SearchResultListItem.kt | 46 ++- .../components/SearchResultsContent.kt | 69 +++-- .../e/apps/ui/compose/screens/SearchScreen.kt | 12 +- .../compose/state/InstallButtonStateMapper.kt | 277 +++++++++++++++++ .../compose/state/InstallStatusReconciler.kt | 92 ++++++ .../ui/compose/state/InstallStatusStream.kt | 131 ++++++++ .../e/apps/ui/search/v2/SearchFragmentV2.kt | 169 +++++++++++ .../e/apps/ui/search/v2/SearchViewModelV2.kt | 108 ++++++- .../state/InstallButtonStateMapperTest.kt | 280 ++++++++++++++++++ .../ui/search/v2/SearchViewModelV2Test.kt | 53 ++-- 12 files changed, 1245 insertions(+), 92 deletions(-) create mode 100644 app/src/main/java/foundation/e/apps/ui/compose/state/InstallButtonStateMapper.kt create mode 100644 app/src/main/java/foundation/e/apps/ui/compose/state/InstallStatusReconciler.kt create mode 100644 app/src/main/java/foundation/e/apps/ui/compose/state/InstallStatusStream.kt create mode 100644 app/src/test/java/foundation/e/apps/ui/compose/state/InstallButtonStateMapperTest.kt diff --git a/app/src/main/java/foundation/e/apps/data/install/AppManagerWrapper.kt b/app/src/main/java/foundation/e/apps/data/install/AppManagerWrapper.kt index ab66813ce..5338c725f 100644 --- a/app/src/main/java/foundation/e/apps/data/install/AppManagerWrapper.kt +++ b/app/src/main/java/foundation/e/apps/data/install/AppManagerWrapper.kt @@ -1,3 +1,21 @@ +/* + * 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.install import android.content.Context @@ -10,6 +28,7 @@ import foundation.e.apps.data.fdroid.FDroidRepository import foundation.e.apps.data.install.models.AppInstall import foundation.e.apps.install.download.data.DownloadProgress import foundation.e.apps.install.workmanager.InstallWorkManager +import timber.log.Timber import javax.inject.Inject import javax.inject.Singleton @@ -115,30 +134,40 @@ class AppManagerWrapper @Inject constructor( val appDownload = getDownloadList() .singleOrNull { it.id.contentEquals(app._id) && it.packageName.contentEquals(app.package_name) } ?: return 0 + return calculateProgress(appDownload, progress) + } + return 0 + } - if (!appDownload.id.contentEquals(app._id) || !appDownload.packageName.contentEquals(app.package_name)) { - return@let - } + suspend fun calculateProgress( + appInstall: AppInstall, + progress: DownloadProgress + ): Int { + val downloadIds = appInstall.downloadIdMap.keys + if (downloadIds.isEmpty()) { + Timber.w("Download request exists but ids not yet populated; show 0% instead of dropping percent.") + return 0 + } - if (!isProgressValidForApp(application, progress)) { - return -1 - } + val totalSizeBytes = progress.totalSizeBytes + .filterKeys { downloadIds.contains(it) } + .values + .sum() - val downloadingMap = progress.totalSizeBytes.filter { item -> - appDownload.downloadIdMap.keys.contains(item.key) && item.value > 0 - } + if (totalSizeBytes <= 0) { + Timber.w("Total download size is less than/equal 0 bytes; show 0% instead of dropping percent.") + return 0 + } - if (appDownload.downloadIdMap.size > downloadingMap.size) { // All files for download are not ready yet - return 0 - } + val downloadedSoFar = progress.bytesDownloadedSoFar + .filterKeys { downloadIds.contains(it) } + .values + .sum() - 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 percent = ((downloadedSoFar / totalSizeBytes.toDouble()) * 100) + .toInt() + .coerceIn(0, 100) + return percent } private suspend fun isProgressValidForApp( @@ -159,7 +188,7 @@ class AppManagerWrapper @Inject constructor( val appDownloadIds = download.downloadIdMap.keys return appDownloadIds.any { id -> downloadProgress.totalSizeBytes.containsKey(id) || - downloadProgress.bytesDownloadedSoFar.containsKey(id) + downloadProgress.bytesDownloadedSoFar.containsKey(id) } } @@ -195,7 +224,10 @@ class AppManagerWrapper @Inject constructor( return Pair(1, 0) } - fun getDownloadingItemStatus(application: Application?, downloadList: List): Status? { + fun getDownloadingItemStatus( + application: Application?, + downloadList: List + ): Status? { application?.let { app -> val downloadingItem = downloadList.find { it.packageName == app.package_name || it.id == app.package_name } diff --git a/app/src/main/java/foundation/e/apps/install/pkg/PwaManager.kt b/app/src/main/java/foundation/e/apps/install/pkg/PwaManager.kt index 649318dcc..d69179139 100644 --- a/app/src/main/java/foundation/e/apps/install/pkg/PwaManager.kt +++ b/app/src/main/java/foundation/e/apps/install/pkg/PwaManager.kt @@ -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 { + val installed = mutableSetOf() + 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 diff --git a/app/src/main/java/foundation/e/apps/ui/compose/components/SearchResultListItem.kt b/app/src/main/java/foundation/e/apps/ui/compose/components/SearchResultListItem.kt index 5db16df99..6b8e78e11 100644 --- a/app/src/main/java/foundation/e/apps/ui/compose/components/SearchResultListItem.kt +++ b/app/src/main/java/foundation/e/apps/ui/compose/components/SearchResultListItem.kt @@ -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 { diff --git a/app/src/main/java/foundation/e/apps/ui/compose/components/SearchResultsContent.kt b/app/src/main/java/foundation/e/apps/ui/compose/components/SearchResultsContent.kt index 7146f0576..07ece0624 100644 --- a/app/src/main/java/foundation/e/apps/ui/compose/components/SearchResultsContent.kt +++ b/app/src/main/java/foundation/e/apps/ui/compose/components/SearchResultsContent.kt @@ -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?, pwaItems: LazyPagingItems?, + playStoreItems: LazyPagingItems?, searchVersion: Int, getScrollPosition: (SearchTabType) -> ScrollPosition?, onScrollPositionChange: (SearchTabType, Int, Int) -> Unit, onTabSelect: (SearchTabType) -> Unit, modifier: Modifier = Modifier, - playStoreItems: LazyPagingItems? = 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 @@ -145,6 +146,7 @@ fun SearchResultsContent( onPrimaryActionClick = onPrimaryActionClick, onShowMoreClick = onShowMoreClick, onPrivacyClick = onPrivacyClick, + installButtonStateProvider = installButtonStateProvider, modifier = Modifier.fillMaxSize(), ) } @@ -159,6 +161,7 @@ fun SearchResultsContent( onPrimaryActionClick = onPrimaryActionClick, onShowMoreClick = onShowMoreClick, onPrivacyClick = onPrivacyClick, + installButtonStateProvider = installButtonStateProvider, modifier = Modifier.fillMaxSize(), ) } @@ -169,17 +172,17 @@ fun SearchResultsContent( @Composable private fun PagingPlayStoreResultList( - items: LazyPagingItems?, + items: LazyPagingItems?, 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( @@ -245,18 +248,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(), @@ -292,9 +302,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 @@ -367,11 +378,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(), @@ -404,7 +423,7 @@ private fun PagingSearchResultList( } @Composable -private fun Application.toSearchResultUiState(): SearchResultListItemState { +private fun Application.toSearchResultUiState(buttonState: InstallButtonState): SearchResultListItemState { if (isPlaceHolder) { return SearchResultListItemState( author = "", @@ -449,7 +468,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, diff --git a/app/src/main/java/foundation/e/apps/ui/compose/screens/SearchScreen.kt b/app/src/main/java/foundation/e/apps/ui/compose/screens/SearchScreen.kt index 657adf71e..5f0ff5405 100644 --- a/app/src/main/java/foundation/e/apps/ui/compose/screens/SearchScreen.kt +++ b/app/src/main/java/foundation/e/apps/ui/compose/screens/SearchScreen.kt @@ -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>? = null, pwaPaging: Flow>? = null, - playStorePaging: Flow>? = null, + playStorePaging: Flow>? = 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( diff --git a/app/src/main/java/foundation/e/apps/ui/compose/state/InstallButtonStateMapper.kt b/app/src/main/java/foundation/e/apps/ui/compose/state/InstallButtonStateMapper.kt new file mode 100644 index 000000000..3f000f345 --- /dev/null +++ b/app/src/main/java/foundation/e/apps/ui/compose/state/InstallButtonStateMapper.kt @@ -0,0 +1,277 @@ +/* + * 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.ui.compose.state + +import androidx.annotation.StringRes +import foundation.e.apps.R +import foundation.e.apps.data.application.data.Application +import foundation.e.apps.data.enums.Status +import foundation.e.apps.data.enums.User +import foundation.e.apps.install.pkg.InstallerService + +/* + * Central UI contract for the primary action button in search results. + * UI layers render purely from this state; business logic must not leak into Composables. + */ +data class InstallButtonState( + val label: ButtonLabel = ButtonLabel(), + val enabled: Boolean = true, + val style: InstallButtonStyle = InstallButtonStyle.AccentOutline, + val showProgressBar: Boolean = false, + val progressPercentText: String? = null, + val actionIntent: InstallButtonAction = InstallButtonAction.NoOp, + @StringRes val snackbarMessageId: Int? = null, + val dialogType: InstallDialogType? = null, + val statusTag: StatusTag = StatusTag.Unknown, + val rawStatus: Status = Status.UNAVAILABLE, +) { + fun isInProgress(): Boolean { + return showProgressBar || actionIntent == InstallButtonAction.CancelDownload || + rawStatus in Status.downloadStatuses || rawStatus == Status.INSTALLING + } +} + +/* + * UI label that can be backed by a string resource or a literal string (e.g., price). + */ +data class ButtonLabel( + @StringRes val resId: Int? = null, + val text: String? = null, +) + +/* + * Visual treatment tokens mapped to Compose theme in the UI layer. + */ +enum class InstallButtonStyle { + AccentFill, // Accent background, white text (matches installed/updatable legacy state) + AccentOutline, // Transparent background, accent stroke + text + Disabled, // Light-grey stroke + text +} + +/* + * Intent describing the side-effect the UI host should execute on click. + */ +enum class InstallButtonAction { + Install, CancelDownload, OpenAppOrPwa, UpdateSelfConfirm, ShowPaidDialog, ShowBlockedSnackbar, NoOp, +} + +/* + * Dialog variants initiated from the button state when action requires confirmation. + */ +enum class InstallDialogType { + SelfUpdateConfirmation, PaidAppDialog, +} + +/* + * Lightweight status marker useful for tests and debugging to assert mapping branch taken. + */ +enum class StatusTag { + Installed, Updatable, UnavailableFree, UnavailablePaid, UnavailableUnsupported, Downloading, Installing, Blocked, InstallationIssue, Unknown, +} + +/* + * Purchase resolution states, mirroring legacy branching. + */ +sealed class PurchaseState { + object Unknown : PurchaseState() + object Loading : PurchaseState() + object Purchased : PurchaseState() + object NotPurchased : PurchaseState() +} + +/* + * Map raw application + contextual signals into a single button state. + * Keep pure: no side effects; callers handle actions. + */ +fun mapAppToInstallState( + app: Application, + user: User, + isAnonymousUser: Boolean, + isUnsupported: Boolean, + faultyResult: Pair?, + purchaseState: PurchaseState, + progressPercent: Int?, + isSelfUpdate: Boolean, + overrideStatus: Status? = null, +): InstallButtonState { + val status = overrideStatus ?: app.status + val percentLabel = progressPercent?.takeIf { it in 0..100 }?.let { "$it%" } + + return when (status) { + Status.INSTALLED -> InstallButtonState( + label = ButtonLabel(resId = R.string.open), + enabled = true, + style = buildStyleFor(status = Status.INSTALLED, enabled = true), + actionIntent = InstallButtonAction.OpenAppOrPwa, + statusTag = StatusTag.Installed, + ) + + Status.UPDATABLE -> { + val unsupported = isUnsupported + InstallButtonState( + label = ButtonLabel(resId = if (unsupported) R.string.not_available else R.string.update), + enabled = true, + style = buildStyleFor(status = Status.UPDATABLE, enabled = true), + actionIntent = if (unsupported) { + InstallButtonAction.NoOp + } else if (isSelfUpdate) { + InstallButtonAction.UpdateSelfConfirm + } else { + InstallButtonAction.Install + }, + dialogType = if (!unsupported && isSelfUpdate) InstallDialogType.SelfUpdateConfirmation else null, + statusTag = StatusTag.Updatable, + ) + } + + Status.UNAVAILABLE -> { + when { + isUnsupported -> InstallButtonState( + label = ButtonLabel(resId = R.string.not_available), + enabled = true, + style = buildStyleFor(Status.UNAVAILABLE, enabled = true), + actionIntent = InstallButtonAction.NoOp, + statusTag = StatusTag.UnavailableUnsupported, + ) + + app.isFree -> InstallButtonState( + label = ButtonLabel(resId = R.string.install), + enabled = true, + style = buildStyleFor(Status.UNAVAILABLE, enabled = true), + actionIntent = InstallButtonAction.Install, + statusTag = StatusTag.UnavailableFree, + ) + + isAnonymousUser -> InstallButtonState( + label = ButtonLabel(text = app.price), + enabled = true, + style = buildStyleFor(Status.UNAVAILABLE, enabled = true), + actionIntent = InstallButtonAction.ShowPaidDialog, + dialogType = InstallDialogType.PaidAppDialog, + statusTag = StatusTag.UnavailablePaid, + ) + + else -> { + when (purchaseState) { + PurchaseState.Loading, PurchaseState.Unknown -> InstallButtonState( + label = ButtonLabel(text = ""), + enabled = false, + style = buildStyleFor(Status.UNAVAILABLE, enabled = false), + showProgressBar = true, + actionIntent = InstallButtonAction.NoOp, + statusTag = StatusTag.UnavailablePaid, + ) + + PurchaseState.Purchased -> InstallButtonState( + label = ButtonLabel(resId = R.string.install), + enabled = true, + style = buildStyleFor(Status.UNAVAILABLE, enabled = true), + actionIntent = InstallButtonAction.Install, + statusTag = StatusTag.UnavailablePaid, + ) + + PurchaseState.NotPurchased -> InstallButtonState( + label = ButtonLabel(text = app.price), + enabled = true, + style = buildStyleFor(Status.UNAVAILABLE, enabled = true), + actionIntent = InstallButtonAction.ShowPaidDialog, + dialogType = InstallDialogType.PaidAppDialog, + statusTag = StatusTag.UnavailablePaid, + ) + } + } + } + } + + Status.QUEUED, Status.AWAITING, Status.DOWNLOADING, Status.DOWNLOADED -> InstallButtonState( + label = ButtonLabel( + resId = if (percentLabel == null) R.string.cancel else null, text = percentLabel + ), + progressPercentText = percentLabel, + enabled = true, + style = buildStyleFor(status, enabled = true), + actionIntent = InstallButtonAction.CancelDownload, + statusTag = StatusTag.Downloading, + rawStatus = status, + ) + + Status.INSTALLING -> InstallButtonState( + label = ButtonLabel(resId = R.string.installing), + enabled = false, + style = buildStyleFor(status, enabled = false), + actionIntent = InstallButtonAction.NoOp, + statusTag = StatusTag.Installing, + rawStatus = status, + ) + + Status.BLOCKED -> { + val messageId = when (user) { + User.ANONYMOUS, User.NO_GOOGLE -> R.string.install_blocked_anonymous + User.GOOGLE -> R.string.install_blocked_google + } + InstallButtonState( + label = buildDefaultBlockedLabel(app), + enabled = true, + style = buildStyleFor(Status.BLOCKED, enabled = true), + actionIntent = InstallButtonAction.ShowBlockedSnackbar, + snackbarMessageId = messageId, + statusTag = StatusTag.Blocked, + rawStatus = app.status, + ) + } + + Status.INSTALLATION_ISSUE -> { + val faulty = faultyResult ?: (false to "") + val incompatible = faulty.second == InstallerService.INSTALL_FAILED_UPDATE_INCOMPATIBLE + InstallButtonState( + label = ButtonLabel(resId = if (incompatible) R.string.update else R.string.retry), + enabled = !faulty.first, + style = buildStyleFor(Status.INSTALLATION_ISSUE, enabled = !faulty.first), + actionIntent = InstallButtonAction.Install, + statusTag = StatusTag.InstallationIssue, + rawStatus = app.status, + ) + } + + else -> InstallButtonState( + label = ButtonLabel(resId = R.string.install), + enabled = true, + style = InstallButtonStyle.AccentOutline, + actionIntent = InstallButtonAction.NoOp, + statusTag = StatusTag.Unknown, + rawStatus = app.status, + ) + } +} + +private fun buildDefaultBlockedLabel(app: Application): ButtonLabel { + val literal = app.price.takeIf { it.isNotBlank() } + return if (literal != null) ButtonLabel(text = literal) else ButtonLabel(resId = R.string.install) +} + +private fun buildStyleFor(status: Status, enabled: Boolean): InstallButtonStyle { + return when { + status == Status.INSTALLED || status == Status.UPDATABLE -> { + if (enabled) InstallButtonStyle.AccentFill else InstallButtonStyle.Disabled + } + + enabled -> InstallButtonStyle.AccentOutline + else -> InstallButtonStyle.Disabled + } +} diff --git a/app/src/main/java/foundation/e/apps/ui/compose/state/InstallStatusReconciler.kt b/app/src/main/java/foundation/e/apps/ui/compose/state/InstallStatusReconciler.kt new file mode 100644 index 000000000..d87dccd65 --- /dev/null +++ b/app/src/main/java/foundation/e/apps/ui/compose/state/InstallStatusReconciler.kt @@ -0,0 +1,92 @@ +/* + * 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.ui.compose.state + +import foundation.e.apps.data.application.ApplicationRepository +import foundation.e.apps.data.application.data.Application +import foundation.e.apps.data.install.AppManagerWrapper +import foundation.e.apps.data.install.models.AppInstall +import foundation.e.apps.install.download.data.DownloadProgress +import javax.inject.Inject +import javax.inject.Singleton + +/* + * Reconciles a network Application with local install state. + * + * Responsibilities: + * - prefer active downloads over package/PWA status + * - compute progress percent when download is active + * - fall back to getFusedAppInstallationStatus for installed/updatable detection + */ +@Singleton +class InstallStatusReconciler @Inject constructor( + private val applicationRepository: ApplicationRepository, + private val appManagerWrapper: AppManagerWrapper, +) { + + data class Result( + val application: Application, + val progressPercent: Int? = null, + ) + + suspend fun reconcile( + app: Application, + snapshot: StatusSnapshot, + progress: DownloadProgress? = null, + ): Result { + // Prefer matching active download + val activeDownload = snapshot.downloads.find { matches(app, it) } + if (activeDownload != null) { + val progressPercent = progressPercent(activeDownload, progress) + app.status = activeDownload.status + return Result(app, progressPercent) + } + + // No active download -> rely on local install status (handles native + PWA) + app.status = applicationRepository.getFusedAppInstallationStatus(app) + return Result(app, null) + } + + private fun matches(app: Application, install: AppInstall): Boolean { + val pkg = app.package_name + val id = app._id + return install.packageName == pkg || + install.id == id || + install.id == pkg + } + + private suspend fun progressPercent( + activeDownload: AppInstall, + progress: DownloadProgress? + ): Int? { + if (progress == null) return null + val percent = appManagerWrapper.calculateProgress(activeDownload, progress) + if (percent in 0..100) return percent + + // Fallback: compute from the last downloadId emitted by DownloadProgress + val id = progress.downloadId.takeIf { it != -1L } + if (id != null && activeDownload.downloadIdMap.containsKey(id)) { + val total = progress.totalSizeBytes[id] ?: return null + if (total <= 0) return null + val done = progress.bytesDownloadedSoFar[id] ?: 0L + return ((done / total.toDouble()) * 100).toInt().coerceIn(0, 100) + } + return null + } +} diff --git a/app/src/main/java/foundation/e/apps/ui/compose/state/InstallStatusStream.kt b/app/src/main/java/foundation/e/apps/ui/compose/state/InstallStatusStream.kt new file mode 100644 index 000000000..797938acf --- /dev/null +++ b/app/src/main/java/foundation/e/apps/ui/compose/state/InstallStatusStream.kt @@ -0,0 +1,131 @@ +/* + * 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 . + * + */ + +/* + * Compose status stream for search results. + * + * Produces a single flow of StatusSnapshot combining: + * - Live download list from AppManagerWrapper (install pipeline) + * - Periodic package-manager snapshot for native apps + * - Periodic PWA snapshot (urls) from PwaManager + * + * Consumers can join this with paging data to refresh only affected items. + */ + +package foundation.e.apps.ui.compose.state + +import androidx.lifecycle.asFlow +import foundation.e.apps.data.install.AppManagerWrapper +import foundation.e.apps.data.install.models.AppInstall +import foundation.e.apps.install.pkg.AppLoungePackageManager +import foundation.e.apps.install.pkg.PwaManager +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.isActive +import kotlinx.coroutines.withContext +import javax.inject.Inject +import javax.inject.Singleton + +/* + * Snapshot of device install state needed for UI reconciliation. + * + * key rules: + * - downloads are emitted exactly as maintained by AppManagerWrapper; consumers decide how to map to UI. + * - installedPackages captures native app presence; version comparison must happen elsewhere. + * - installedPwaUrls captures PWA presence by URL because PWAs do not use package names. + */ +data class StatusSnapshot( + val downloads: List, + val installedPackages: Set, + val installedPwaUrls: Set, +) + +@Singleton +class InstallStatusStream @Inject constructor( + private val appManagerWrapper: AppManagerWrapper, + private val appLoungePackageManager: AppLoungePackageManager, + private val pwaManager: PwaManager, +) { + + /* + * Expose a cold flow producing StatusSnapshot. + * Callers should collect in a scoped coroutine (e.g., ViewModel scope). + */ + fun stream( + scope: CoroutineScope, + packagePollIntervalMs: Long = DEFAULT_PACKAGE_POLL_INTERVAL_MS, + pwaPollIntervalMs: Long = DEFAULT_PWA_POLL_INTERVAL_MS, + ): Flow { + val downloadsFlow = appManagerWrapper + .getDownloadLiveList() + .asFlow() + .map { it.orEmpty() } + .distinctUntilChanged() + + val installedPackagesFlow = pollingFlow(scope, packagePollIntervalMs) { + // Package manager queries are I/O bound; keep them off the main thread. + withContext(Dispatchers.IO) { + appLoungePackageManager.getAllUserApps() + .map { it.packageName } + .toSet() + } + }.distinctUntilChanged() + + val installedPwaFlow = pollingFlow(scope, pwaPollIntervalMs) { + withContext(Dispatchers.IO) { pwaManager.getInstalledPwaUrls() } + }.distinctUntilChanged() + + return combine( + downloadsFlow, + installedPackagesFlow, + installedPwaFlow + ) { downloads, packages, pwas -> + StatusSnapshot( + downloads = downloads, + installedPackages = packages, + installedPwaUrls = pwas, + ) + } + } + + /* + * Helper to emit immediately and then on a fixed interval until the scope is cancelled. + */ + private fun pollingFlow( + scope: CoroutineScope, + intervalMs: Long, + block: suspend () -> T, + ): Flow = flow { + emit(block()) + while (scope.isActive) { + delay(intervalMs) + emit(block()) + } + } + + companion object { + private const val DEFAULT_PACKAGE_POLL_INTERVAL_MS = 30_000L + private const val DEFAULT_PWA_POLL_INTERVAL_MS = 30_000L + } +} diff --git a/app/src/main/java/foundation/e/apps/ui/search/v2/SearchFragmentV2.kt b/app/src/main/java/foundation/e/apps/ui/search/v2/SearchFragmentV2.kt index 5759b1dbb..2b26fd189 100644 --- a/app/src/main/java/foundation/e/apps/ui/search/v2/SearchFragmentV2.kt +++ b/app/src/main/java/foundation/e/apps/ui/search/v2/SearchFragmentV2.kt @@ -20,30 +20,113 @@ package foundation.e.apps.ui.search.v2 import android.os.Bundle import android.view.View +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels import androidx.fragment.app.viewModels +import androidx.lifecycle.asFlow import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController +import com.google.android.material.snackbar.Snackbar import dagger.hilt.android.AndroidEntryPoint import foundation.e.apps.R import foundation.e.apps.data.application.data.Application +import foundation.e.apps.data.enums.Status +import foundation.e.apps.data.enums.User +import foundation.e.apps.data.enums.isInitialized +import foundation.e.apps.data.enums.isUnFiltered +import foundation.e.apps.install.download.data.DownloadProgress +import foundation.e.apps.ui.AppInfoFetchViewModel +import foundation.e.apps.ui.AppProgressViewModel +import foundation.e.apps.ui.MainActivityViewModel +import foundation.e.apps.ui.application.subFrags.ApplicationDialogFragment import foundation.e.apps.ui.compose.screens.SearchScreen +import foundation.e.apps.ui.compose.state.InstallButtonAction +import foundation.e.apps.ui.compose.state.InstallButtonState +import foundation.e.apps.ui.compose.state.PurchaseState +import foundation.e.apps.ui.compose.state.mapAppToInstallState import foundation.e.apps.ui.compose.theme.AppTheme +import kotlinx.coroutines.launch @AndroidEntryPoint class SearchFragmentV2 : Fragment(R.layout.fragment_search_v2) { private val searchViewModel: SearchViewModelV2 by viewModels() + private val mainActivityViewModel: MainActivityViewModel by activityViewModels() + private val appProgressViewModel: AppProgressViewModel by viewModels() + private val appInfoFetchViewModel: AppInfoFetchViewModel by viewModels() override fun onViewCreated(view: View, savedInstanceState: Bundle?) { val composeView = view.findViewById(R.id.composeView) composeView.setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + + // Ensure DownloadProgress emissions reach the ViewModel even if Compose does not recompose. + viewLifecycleOwner.lifecycleScope.launch { + appProgressViewModel.downloadProgress.asFlow().collect { progress -> + searchViewModel.updateDownloadProgress(copyProgress(progress)) + } + } + composeView.setContent { AppTheme { val uiState by searchViewModel.uiState.collectAsStateWithLifecycle() + val user = mainActivityViewModel.getUser() + val isAnonymous = user == User.ANONYMOUS + val downloadProgress by appProgressViewModel.downloadProgress.observeAsState() + val progressPercentMap by searchViewModel.progressPercentByKey.collectAsState() + val statusByKey by searchViewModel.statusByKey.collectAsState() + + LaunchedEffect(downloadProgress) { + // Retain Compose-based updates as a secondary path for safety. + downloadProgress?.let { + searchViewModel.updateDownloadProgress(copyProgress(it)) + } + } + + val installButtonStateProvider: (Application) -> InstallButtonState = { app -> + val progressKey = app.package_name.takeIf { it.isNotBlank() } ?: app._id + val progressPercent = progressPercentMap[progressKey] + val overrideStatus = statusByKey[progressKey] + val purchaseState = when { + app.isFree -> PurchaseState.Unknown + app.isPurchased -> PurchaseState.Purchased + else -> PurchaseState.NotPurchased + } + val isBlocked = appInfoFetchViewModel.isAppInBlockedList(app) + val isUnsupported = + app.filterLevel.isInitialized() && !app.filterLevel.isUnFiltered() + val originalStatus = app.status + val effectiveApp = when { + isBlocked -> { + app.status = Status.BLOCKED + app + } + + else -> app + } + mapAppToInstallState( + app = effectiveApp, + user = user, + isAnonymousUser = isAnonymous, + isUnsupported = isUnsupported, + faultyResult = null, + purchaseState = purchaseState, + progressPercent = progressPercent, + isSelfUpdate = app.package_name == requireContext().packageName, + overrideStatus = overrideStatus, + ) + .also { + // Restore original status to avoid mutating shared paging instance. + app.status = originalStatus + } + } + SearchScreen( uiState = uiState, onQueryChange = searchViewModel::onQueryChanged, @@ -61,11 +144,97 @@ class SearchFragmentV2 : Fragment(R.layout.fragment_search_v2) { searchViewModel.updateScrollPosition(tab, index, offset) }, onResultClick = { application -> navigateToApplication(application) }, + onPrimaryAction = { application, action -> + handlePrimaryAction(application, action) + }, + installButtonStateProvider = installButtonStateProvider, ) } } } + private fun copyProgress(progress: DownloadProgress): DownloadProgress { + return DownloadProgress( + totalSizeBytes = progress.totalSizeBytes.toMutableMap(), + bytesDownloadedSoFar = progress.bytesDownloadedSoFar.toMutableMap(), + status = progress.status.toMutableMap(), + downloadId = progress.downloadId + ) + } + + private fun handlePrimaryAction(app: Application, action: InstallButtonAction) { + when (action) { + InstallButtonAction.Install, + InstallButtonAction.UpdateSelfConfirm -> { + mainActivityViewModel.verifyUiFilter(app) { + if (mainActivityViewModel.shouldShowPaidAppsSnackBar(app)) { + return@verifyUiFilter + } + mainActivityViewModel.getApplication(app) + } + } + + InstallButtonAction.CancelDownload -> mainActivityViewModel.cancelDownload(app) + InstallButtonAction.OpenAppOrPwa -> { + if (app.is_pwa) { + mainActivityViewModel.launchPwa(app) + } else { + mainActivityViewModel.getLaunchIntentForPackageName(app.package_name)?.let { + requireContext().startActivity(it) + } + } + } + + InstallButtonAction.ShowPaidDialog -> { + when (mainActivityViewModel.getUser()) { + User.GOOGLE -> showPaidAppMessage(app) + else -> showPaidSnackbar() + } + } + + InstallButtonAction.ShowBlockedSnackbar -> { + showBlockedSnackbar(app) + } + + InstallButtonAction.NoOp -> Unit + } + } + + private fun showPaidAppMessage(application: Application) { + ApplicationDialogFragment( + title = getString(R.string.dialog_title_paid_app, application.name), + message = getString( + R.string.dialog_paidapp_message, + application.name, + application.price + ), + positiveButtonText = getString(R.string.dialog_confirm), + positiveButtonAction = { + mainActivityViewModel.getApplication(application) + }, + cancelButtonText = getString(R.string.dialog_cancel), + ).show(childFragmentManager, "SearchFragmentV2") + } + + private fun showPaidSnackbar() { + view?.let { + Snackbar.make(it, getString(R.string.paid_app_anonymous_message), Snackbar.LENGTH_SHORT) + .show() + } + } + + private fun showBlockedSnackbar(application: Application) { + val errorMsg = when (mainActivityViewModel.getUser()) { + User.ANONYMOUS, + User.NO_GOOGLE -> getString(R.string.install_blocked_anonymous) + + User.GOOGLE -> getString(R.string.install_blocked_google) + } + if (errorMsg.isNotBlank()) { + view?.let { Snackbar.make(it, errorMsg, Snackbar.LENGTH_SHORT).show() } + } + } + private fun navigateToApplication(application: Application) { val packageName = application.package_name val id = application._id diff --git a/app/src/main/java/foundation/e/apps/ui/search/v2/SearchViewModelV2.kt b/app/src/main/java/foundation/e/apps/ui/search/v2/SearchViewModelV2.kt index a0d1702a6..d948a763e 100644 --- a/app/src/main/java/foundation/e/apps/ui/search/v2/SearchViewModelV2.kt +++ b/app/src/main/java/foundation/e/apps/ui/search/v2/SearchViewModelV2.kt @@ -18,31 +18,43 @@ package foundation.e.apps.ui.search.v2 +import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.paging.PagingData import androidx.paging.cachedIn +import androidx.paging.map import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext import foundation.e.apps.data.Stores import foundation.e.apps.data.application.data.Application +import foundation.e.apps.data.application.utils.toApplication import foundation.e.apps.data.cleanapk.CleanApkRetrofit import foundation.e.apps.data.enums.Source.OPEN_SOURCE import foundation.e.apps.data.enums.Source.PLAY_STORE import foundation.e.apps.data.enums.Source.PWA +import foundation.e.apps.data.enums.Status import foundation.e.apps.data.preference.AppLoungePreference import foundation.e.apps.data.search.CleanApkSearchParams import foundation.e.apps.data.search.PlayStorePagingRepository import foundation.e.apps.data.search.SearchPagingRepository import foundation.e.apps.data.search.SuggestionSource +import foundation.e.apps.install.download.data.DownloadProgress +import foundation.e.apps.ui.compose.state.InstallStatusReconciler +import foundation.e.apps.ui.compose.state.InstallStatusStream +import foundation.e.apps.ui.compose.state.StatusSnapshot import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -81,7 +93,10 @@ class SearchViewModelV2 @Inject constructor( private val appLoungePreference: AppLoungePreference, private val searchPagingRepository: SearchPagingRepository, private val playStorePagingRepository: PlayStorePagingRepository, - private val stores: Stores + private val stores: Stores, + private val installStatusStream: InstallStatusStream, + private val installStatusReconciler: InstallStatusReconciler, + @ApplicationContext private val appContext: Context, ) : ViewModel() { private val initialVisibleTabs = resolveVisibleTabs() @@ -101,20 +116,26 @@ class SearchViewModelV2 @Inject constructor( private val searchRequests = MutableStateFlow(null) private val _scrollPositions = MutableStateFlow>(emptyMap()) + private val statusSnapshot = MutableStateFlow(null) + private val downloadProgress = MutableStateFlow(null) + private val _progressPercentByKey = MutableStateFlow>(emptyMap()) + val progressPercentByKey: StateFlow> = _progressPercentByKey.asStateFlow() + private val _statusByKey = MutableStateFlow>(emptyMap()) + val statusByKey: StateFlow> = _statusByKey.asStateFlow() val fossPagingFlow = buildCleanApkPagingFlow( tab = SearchTabType.OPEN_SOURCE, appSource = CleanApkRetrofit.APP_SOURCE_FOSS, appType = CleanApkRetrofit.APP_TYPE_NATIVE - ) + ).withStatus() val pwaPagingFlow = buildCleanApkPagingFlow( tab = SearchTabType.PWA, appSource = CleanApkRetrofit.APP_SOURCE_ANY, appType = CleanApkRetrofit.APP_TYPE_PWA - ) + ).withStatus() - val playStorePagingFlow = buildPlayStorePagingFlow() + val playStorePagingFlow = buildPlayStorePagingFlow().withStatus() private var suggestionJob: Job? = null @@ -123,6 +144,7 @@ class SearchViewModelV2 @Inject constructor( stores.enabledStoresFlow .collect { handleStoreSelectionChanged() } } + observeInstallStatus() } fun onQueryChanged(newQuery: String) { @@ -284,6 +306,19 @@ class SearchViewModelV2 @Inject constructor( } } + private fun observeInstallStatus() { + viewModelScope.launch { + installStatusStream.stream(viewModelScope).collect { snapshot -> + statusSnapshot.value = snapshot + } + } + // TODO: wire DownloadProgress from AppProgressViewModel when available. + } + + fun updateDownloadProgress(progress: DownloadProgress?) { + downloadProgress.value = progress + } + private fun resolveVisibleTabs(): List = stores.getStores().mapNotNull { (key, _) -> when (key) { @@ -313,7 +348,11 @@ class SearchViewModelV2 @Inject constructor( appSource = appSource, appType = appType ) - ) + ).map { pagingData -> + pagingData.map { app -> + reconcile(app) + } + } } } .flatMapLatest { it } @@ -332,12 +371,69 @@ class SearchViewModelV2 @Inject constructor( playStorePagingRepository.playStoreSearch( query = request.query, pageSize = DEFAULT_PLAY_STORE_PAGE_SIZE - ) + ).map { pagingData -> + pagingData.map { app -> + reconcile(app.toApplication(appContext)) + } + } } } .flatMapLatest { it } .cachedIn(viewModelScope) + private fun Flow>.withStatus(): Flow> = + combine( + this, + statusSnapshot.filterNotNull(), + downloadProgress + ) { paging: PagingData, snapshot: StatusSnapshot, progress: DownloadProgress? -> + paging.map { app -> reconcile(app, snapshot, progress) } + } + + private suspend fun reconcile( + app: Application, + snapshot: StatusSnapshot? = statusSnapshot.value, + progress: DownloadProgress? = downloadProgress.value, + ): Application { + val safeSnapshot = snapshot ?: return app + val result = installStatusReconciler.reconcile( + app = app, + snapshot = safeSnapshot, + progress = progress + ) + recordProgress(result.application, result.progressPercent) + recordStatus(result.application) + return result.application + } + + fun progressPercentFor(app: Application): Int? { + return _progressPercentByKey.value[keyFor(app)] + } + + private fun recordProgress(app: Application, percent: Int?) { + val key = keyFor(app) + _progressPercentByKey.update { current -> + when { + percent == null && current.containsKey(key) -> current - key + percent != null && current[key] != percent -> current + (key to percent) + else -> current + } + } + } + + fun statusFor(app: Application): Status? = _statusByKey.value[keyFor(app)] + + private fun recordStatus(app: Application) { + val key = keyFor(app) + _statusByKey.update { current -> + if (current[key] != app.status) current + (key to app.status) else current + } + } + + private fun keyFor(app: Application): String { + return app.package_name.takeIf { it.isNotBlank() } ?: app._id + } + companion object { private const val DEFAULT_PLAY_STORE_PAGE_SIZE = 20 } diff --git a/app/src/test/java/foundation/e/apps/ui/compose/state/InstallButtonStateMapperTest.kt b/app/src/test/java/foundation/e/apps/ui/compose/state/InstallButtonStateMapperTest.kt new file mode 100644 index 000000000..a6d43c781 --- /dev/null +++ b/app/src/test/java/foundation/e/apps/ui/compose/state/InstallButtonStateMapperTest.kt @@ -0,0 +1,280 @@ +/* + * 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.ui.compose.state + +import foundation.e.apps.R +import foundation.e.apps.data.application.data.Application +import foundation.e.apps.data.enums.Source +import foundation.e.apps.data.enums.Status +import foundation.e.apps.data.enums.User +import foundation.e.apps.install.pkg.InstallerService +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class InstallButtonStateMapperTest { + + private fun baseApp( + status: Status, + isFree: Boolean = true, + price: String = "", + isPwa: Boolean = false, + ) = Application( + _id = "id", + name = "App", + package_name = "pkg", + source = Source.PLAY_STORE, + price = price, + isFree = isFree, + is_pwa = isPwa, + status = status, + ) + + @Test + fun installed_maps_to_open() { + val state = mapAppToInstallState( + app = baseApp(Status.INSTALLED), + user = User.GOOGLE, + isAnonymousUser = false, + isUnsupported = false, + faultyResult = null, + purchaseState = PurchaseState.Unknown, + progressPercent = null, + isSelfUpdate = false, + ) + assertEquals(R.string.open, state.label.resId) + assertEquals(InstallButtonAction.OpenAppOrPwa, state.actionIntent) + assertEquals(StatusTag.Installed, state.statusTag) + assertEquals(InstallButtonStyle.AccentFill, state.style) + assertTrue(state.enabled) + } + + @Test + fun updatable_self_update_sets_dialog_and_intent() { + val state = mapAppToInstallState( + app = baseApp(Status.UPDATABLE), + user = User.GOOGLE, + isAnonymousUser = false, + isUnsupported = false, + faultyResult = null, + purchaseState = PurchaseState.Unknown, + progressPercent = null, + isSelfUpdate = true, + ) + assertEquals(R.string.update, state.label.resId) + assertEquals(InstallButtonAction.UpdateSelfConfirm, state.actionIntent) + assertEquals(InstallDialogType.SelfUpdateConfirmation, state.dialogType) + assertEquals(StatusTag.Updatable, state.statusTag) + assertEquals(InstallButtonStyle.AccentFill, state.style) + } + + @Test + fun updatable_unsupported_is_noop() { + val state = mapAppToInstallState( + app = baseApp(Status.UPDATABLE), + user = User.GOOGLE, + isAnonymousUser = false, + isUnsupported = true, + faultyResult = null, + purchaseState = PurchaseState.Unknown, + progressPercent = null, + isSelfUpdate = false, + ) + assertEquals(R.string.not_available, state.label.resId) + assertEquals(InstallButtonAction.NoOp, state.actionIntent) + assertEquals(StatusTag.Updatable, state.statusTag) + } + + @Test + fun unavailable_free_installs() { + val state = mapAppToInstallState( + app = baseApp(Status.UNAVAILABLE, isFree = true), + user = User.GOOGLE, + isAnonymousUser = false, + isUnsupported = false, + faultyResult = null, + purchaseState = PurchaseState.Unknown, + progressPercent = null, + isSelfUpdate = false, + ) + assertEquals(R.string.install, state.label.resId) + assertEquals(InstallButtonAction.Install, state.actionIntent) + assertEquals(StatusTag.UnavailableFree, state.statusTag) + } + + @Test + fun unavailable_paid_anonymous_shows_price_and_paid_dialog() { + val state = mapAppToInstallState( + app = baseApp(Status.UNAVAILABLE, isFree = false, price = "$1.99"), + user = User.ANONYMOUS, + isAnonymousUser = true, + isUnsupported = false, + faultyResult = null, + purchaseState = PurchaseState.NotPurchased, + progressPercent = null, + isSelfUpdate = false, + ) + assertEquals("$1.99", state.label.text) + assertEquals(InstallButtonAction.ShowPaidDialog, state.actionIntent) + assertEquals(InstallDialogType.PaidAppDialog, state.dialogType) + assertEquals(StatusTag.UnavailablePaid, state.statusTag) + } + + @Test + fun unavailable_paid_purchased_installs() { + val state = mapAppToInstallState( + app = baseApp(Status.UNAVAILABLE, isFree = false, price = "$1.99"), + user = User.GOOGLE, + isAnonymousUser = false, + isUnsupported = false, + faultyResult = null, + purchaseState = PurchaseState.Purchased, + progressPercent = null, + isSelfUpdate = false, + ) + assertEquals(R.string.install, state.label.resId) + assertEquals(InstallButtonAction.Install, state.actionIntent) + assertEquals(StatusTag.UnavailablePaid, state.statusTag) + } + + @Test + fun unavailable_unsupported_noop() { + val state = mapAppToInstallState( + app = baseApp(Status.UNAVAILABLE, isFree = false, price = "$1.99"), + user = User.GOOGLE, + isAnonymousUser = false, + isUnsupported = true, + faultyResult = null, + purchaseState = PurchaseState.Unknown, + progressPercent = null, + isSelfUpdate = false, + ) + assertEquals(R.string.not_available, state.label.resId) + assertEquals(InstallButtonAction.NoOp, state.actionIntent) + assertEquals(StatusTag.UnavailableUnsupported, state.statusTag) + } + + @Test + fun downloading_with_progress_shows_percent_and_cancel_intent() { + val state = mapAppToInstallState( + app = baseApp(Status.DOWNLOADING), + user = User.GOOGLE, + isAnonymousUser = false, + isUnsupported = false, + faultyResult = null, + purchaseState = PurchaseState.Unknown, + progressPercent = 42, + isSelfUpdate = false, + ) + assertEquals("42%", state.label.text) + assertEquals(InstallButtonAction.CancelDownload, state.actionIntent) + assertEquals(StatusTag.Downloading, state.statusTag) + } + + @Test + fun installing_disabled() { + val state = mapAppToInstallState( + app = baseApp(Status.INSTALLING), + user = User.GOOGLE, + isAnonymousUser = false, + isUnsupported = false, + faultyResult = null, + purchaseState = PurchaseState.Unknown, + progressPercent = null, + isSelfUpdate = false, + ) + assertEquals(R.string.installing, state.label.resId) + assertFalse(state.enabled) + assertEquals(StatusTag.Installing, state.statusTag) + } + + @Test + fun blocked_snackbar_differs_by_user() { + val stateAnon = mapAppToInstallState( + app = baseApp(Status.BLOCKED), + user = User.ANONYMOUS, + isAnonymousUser = true, + isUnsupported = false, + faultyResult = null, + purchaseState = PurchaseState.Unknown, + progressPercent = null, + isSelfUpdate = false, + ) + assertEquals(R.string.install_blocked_anonymous, stateAnon.snackbarMessageId) + assertEquals(InstallButtonAction.ShowBlockedSnackbar, stateAnon.actionIntent) + + val stateGoogle = mapAppToInstallState( + app = baseApp(Status.BLOCKED), + user = User.GOOGLE, + isAnonymousUser = false, + isUnsupported = false, + faultyResult = null, + purchaseState = PurchaseState.Unknown, + progressPercent = null, + isSelfUpdate = false, + ) + assertEquals(R.string.install_blocked_google, stateGoogle.snackbarMessageId) + } + + @Test + fun installation_issue_faulty_disables_and_uses_retry_or_update() { + val faultyState = mapAppToInstallState( + app = baseApp(Status.INSTALLATION_ISSUE), + user = User.GOOGLE, + isAnonymousUser = false, + isUnsupported = false, + faultyResult = true to "ERROR", + purchaseState = PurchaseState.Unknown, + progressPercent = null, + isSelfUpdate = false, + ) + assertFalse(faultyState.enabled) + assertEquals(R.string.retry, faultyState.label.resId) + + val incompatibleState = mapAppToInstallState( + app = baseApp(Status.INSTALLATION_ISSUE), + user = User.GOOGLE, + isAnonymousUser = false, + isUnsupported = false, + faultyResult = true to InstallerService.INSTALL_FAILED_UPDATE_INCOMPATIBLE, + purchaseState = PurchaseState.Unknown, + progressPercent = null, + isSelfUpdate = false, + ) + assertEquals(R.string.update, incompatibleState.label.resId) + } + + @Test + fun purchase_needed_status_defaults_to_noop() { + val app = baseApp(Status.PURCHASE_NEEDED) + val state = mapAppToInstallState( + app = app, + user = User.GOOGLE, + isAnonymousUser = false, + isUnsupported = false, + faultyResult = null, + purchaseState = PurchaseState.Unknown, + progressPercent = null, + isSelfUpdate = false, + ) + assertEquals(InstallButtonAction.NoOp, state.actionIntent) + assertEquals(StatusTag.Unknown, state.statusTag) + } +} diff --git a/app/src/test/java/foundation/e/apps/ui/search/v2/SearchViewModelV2Test.kt b/app/src/test/java/foundation/e/apps/ui/search/v2/SearchViewModelV2Test.kt index 7e4e7aa1b..b32baa8bd 100644 --- a/app/src/test/java/foundation/e/apps/ui/search/v2/SearchViewModelV2Test.kt +++ b/app/src/test/java/foundation/e/apps/ui/search/v2/SearchViewModelV2Test.kt @@ -18,9 +18,9 @@ package foundation.e.apps.ui.search.v2 +import android.content.Context import androidx.paging.AsyncPagingDataDiffer import androidx.paging.PagingData -import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.ListUpdateCallback import com.aurora.gplayapi.data.models.App import foundation.e.apps.data.Stores @@ -36,7 +36,11 @@ import foundation.e.apps.data.search.FakeSuggestionSource import foundation.e.apps.data.search.PlayStorePagingRepository import foundation.e.apps.data.search.SearchPagingRepository import foundation.e.apps.ui.applicationlist.ApplicationDiffUtil +import foundation.e.apps.ui.compose.state.InstallStatusReconciler +import foundation.e.apps.ui.compose.state.InstallStatusStream +import foundation.e.apps.ui.compose.state.StatusSnapshot import foundation.e.apps.util.MainCoroutineRule +import io.mockk.coEvery import io.mockk.every import io.mockk.mockk import io.mockk.slot @@ -66,6 +70,9 @@ class SearchViewModelV2Test { private lateinit var searchPagingRepository: SearchPagingRepository private lateinit var playStorePagingRepository: PlayStorePagingRepository private lateinit var stores: Stores + private lateinit var installStatusStream: InstallStatusStream + private lateinit var installStatusReconciler: InstallStatusReconciler + private lateinit var appContext: Context private var playStoreSelected = true private var openSourceSelected = true private var pwaSelected = false @@ -77,6 +84,9 @@ class SearchViewModelV2Test { preference = mockk(relaxed = true) searchPagingRepository = mockk(relaxed = true) playStorePagingRepository = mockk(relaxed = true) + installStatusStream = mockk(relaxed = true) + installStatusReconciler = mockk(relaxed = true) + appContext = mockk(relaxed = true) every { preference.isPlayStoreSelected() } answers { playStoreSelected } every { preference.isOpenSourceSelected() } answers { openSourceSelected } @@ -87,6 +97,16 @@ class SearchViewModelV2Test { every { preference.disableOpenSource() } answers { openSourceSelected = false } every { preference.enablePwa() } answers { pwaSelected = true } every { preference.disablePwa() } answers { pwaSelected = false } + every { installStatusStream.stream(any(), any(), any()) } returns flowOf( + StatusSnapshot( + downloads = emptyList(), + installedPackages = emptySet(), + installedPwaUrls = emptySet(), + ) + ) + coEvery { installStatusReconciler.reconcile(any(), any(), any()) } answers { + InstallStatusReconciler.Result(args[0] as Application) + } buildViewModel() } @@ -353,7 +373,7 @@ class SearchViewModelV2Test { viewModel.onSearchSubmitted("apps") val pagingData = viewModel.playStorePagingFlow.first() - val items = collectPlayStoreApps(pagingData) + val items = collectApplications(pagingData) assertTrue(items.isEmpty()) verify(exactly = 0) { playStorePagingRepository.playStoreSearch(any(), any()) } @@ -372,7 +392,7 @@ class SearchViewModelV2Test { stores.enableStore(Source.OPEN_SOURCE) runStoreUpdates() val pagingData = viewModel.playStorePagingFlow.first() - val items = collectPlayStoreApps(pagingData) + val items = collectApplications(pagingData) assertTrue(items.isEmpty()) verify(exactly = 0) { playStorePagingRepository.playStoreSearch(any(), any()) } @@ -461,19 +481,6 @@ class SearchViewModelV2Test { return differ.snapshot().items } - private suspend fun collectPlayStoreApps(pagingData: PagingData): List { - val differ = AsyncPagingDataDiffer( - diffCallback = PlayStoreAppDiffUtil(), - updateCallback = NoopListCallback(), - mainDispatcher = mainCoroutineRule.testDispatcher, - workerDispatcher = mainCoroutineRule.testDispatcher - ) - - differ.submitData(pagingData) - mainCoroutineRule.testDispatcher.scheduler.advanceUntilIdle() - return differ.snapshot().items - } - private fun visibleTabs(): List = buildList { if (playStoreSelected) add(SearchTabType.COMMON_APPS) if (openSourceSelected) add(SearchTabType.OPEN_SOURCE) @@ -487,8 +494,12 @@ class SearchViewModelV2Test { preference, searchPagingRepository, playStorePagingRepository, - stores + stores, + installStatusStream, + installStatusReconciler, + appContext, ) + runStoreUpdates() } private class NoopListCallback : ListUpdateCallback { @@ -501,14 +512,6 @@ class SearchViewModelV2Test { override fun onChanged(position: Int, count: Int, payload: Any?) = Unit } - private class PlayStoreAppDiffUtil : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: App, newItem: App): Boolean = - oldItem.packageName == newItem.packageName - - override fun areContentsTheSame(oldItem: App, newItem: App): Boolean = - oldItem == newItem - } - private fun sampleApp(name: String) = Application( name = name, _id = name, -- GitLab From 384c09f4da7506e937d5f8cf24f8063ed055cdb1 Mon Sep 17 00:00:00 2001 From: Fahim Masud Choudhury Date: Fri, 23 Jan 2026 19:54:36 +0600 Subject: [PATCH 02/17] test: cover install state sync flows Add unit/instrumentation coverage for install button mapping, progress reconciliation, and search action rendering so expected behaviors are documented. Refine install-state helpers and PWA URL lookup to satisfy static analysis without changing behavior. --- .../components/SearchResultListItemTest.kt | 3 +- .../components/SearchResultsContentTest.kt | 118 ++++++++++++++- .../e/apps/data/install/AppManagerWrapper.kt | 61 +++----- .../e/apps/install/pkg/PwaManager.kt | 21 +-- .../components/SearchResultListItem.kt | 4 - .../components/SearchResultsContent.kt | 41 +----- .../e/apps/ui/compose/screens/SearchScreen.kt | 1 - .../compose/state/InstallButtonStateMapper.kt | 25 +++- .../compose/state/InstallStatusReconciler.kt | 37 +++-- .../e/apps/ui/search/v2/SearchFragmentV2.kt | 5 +- .../e/apps/ui/search/v2/SearchViewModelV2.kt | 1 + .../install/AppManagerWrapperProgressTest.kt | 88 ++++++++++++ .../e/apps/install/pkg/PwaManagerTest.kt | 103 +++++++++++++ .../state/InstallButtonStateMapperTest.kt | 33 +++++ .../state/InstallStatusReconcilerTest.kt | 136 ++++++++++++++++++ .../compose/state/InstallStatusStreamTest.kt | 104 ++++++++++++++ .../ui/search/v2/SearchViewModelV2Test.kt | 61 ++++++++ 17 files changed, 720 insertions(+), 122 deletions(-) create mode 100644 app/src/test/java/foundation/e/apps/data/install/AppManagerWrapperProgressTest.kt create mode 100644 app/src/test/java/foundation/e/apps/install/pkg/PwaManagerTest.kt create mode 100644 app/src/test/java/foundation/e/apps/ui/compose/state/InstallStatusReconcilerTest.kt create mode 100644 app/src/test/java/foundation/e/apps/ui/compose/state/InstallStatusStreamTest.kt diff --git a/app/src/androidTest/java/foundation/e/apps/ui/compose/components/SearchResultListItemTest.kt b/app/src/androidTest/java/foundation/e/apps/ui/compose/components/SearchResultListItemTest.kt index c0da2983b..0a538c4ce 100644 --- a/app/src/androidTest/java/foundation/e/apps/ui/compose/components/SearchResultListItemTest.kt +++ b/app/src/androidTest/java/foundation/e/apps/ui/compose/components/SearchResultListItemTest.kt @@ -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) diff --git a/app/src/androidTest/java/foundation/e/apps/ui/compose/components/SearchResultsContentTest.kt b/app/src/androidTest/java/foundation/e/apps/ui/compose/components/SearchResultsContentTest.kt index 6b7ae14eb..6be7fd9cc 100644 --- a/app/src/androidTest/java/foundation/e/apps/ui/compose/components/SearchResultsContentTest.kt +++ b/app/src/androidTest/java/foundation/e/apps/ui/compose/components/SearchResultsContentTest.kt @@ -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() @@ -250,18 +263,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, selectedTab: SearchTabType, fossPagingData: PagingData, pwaPagingData: PagingData? = null, + playStorePagingData: PagingData? = 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 +379,13 @@ class SearchResultsContentTest { selectedTab = selectedTab, fossItems = fossItems, pwaItems = pwaItems, + playStoreItems = playStoreItems, searchVersion = searchVersion, getScrollPosition = { null }, onScrollPositionChange = { _, _, _ -> }, onTabSelect = {}, + onPrimaryActionClick = onPrimaryActionClick, + installButtonStateProvider = installButtonStateProvider, ) } } @@ -294,4 +406,8 @@ class SearchResultsContentTest { refresh = LoadState.NotLoading(endOfPaginationReached = false) ) ) + + private fun defaultInstallButtonState() = InstallButtonState( + label = ButtonLabel(resId = R.string.open), + ) } diff --git a/app/src/main/java/foundation/e/apps/data/install/AppManagerWrapper.kt b/app/src/main/java/foundation/e/apps/data/install/AppManagerWrapper.kt index 5338c725f..77fd2c9e1 100644 --- a/app/src/main/java/foundation/e/apps/data/install/AppManagerWrapper.kt +++ b/app/src/main/java/foundation/e/apps/data/install/AppManagerWrapper.kt @@ -28,7 +28,6 @@ import foundation.e.apps.data.fdroid.FDroidRepository import foundation.e.apps.data.install.models.AppInstall import foundation.e.apps.install.download.data.DownloadProgress import foundation.e.apps.install.workmanager.InstallWorkManager -import timber.log.Timber import javax.inject.Inject import javax.inject.Singleton @@ -144,54 +143,28 @@ class AppManagerWrapper @Inject constructor( progress: DownloadProgress ): Int { val downloadIds = appInstall.downloadIdMap.keys - if (downloadIds.isEmpty()) { - Timber.w("Download request exists but ids not yet populated; show 0% instead of dropping percent.") - return 0 - } - - val totalSizeBytes = progress.totalSizeBytes - .filterKeys { downloadIds.contains(it) } - .values - .sum() - - if (totalSizeBytes <= 0) { - Timber.w("Total download size is less than/equal 0 bytes; show 0% instead of dropping percent.") - return 0 + var percent = 0 + + if (downloadIds.isNotEmpty()) { + val totalSizeBytes = progress.totalSizeBytes + .filterKeys { downloadIds.contains(it) } + .values + .sum() + + if (totalSizeBytes > 0) { + val downloadedSoFar = progress.bytesDownloadedSoFar + .filterKeys { downloadIds.contains(it) } + .values + .sum() + percent = ((downloadedSoFar / totalSizeBytes.toDouble()) * PERCENTAGE_MULTIPLIER) + .toInt() + .coerceIn(0, PERCENTAGE_MULTIPLIER) + } } - 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( - application: Application, - downloadProgress: DownloadProgress - ): Boolean { - val download = getDownloadList().singleOrNull { - it.id == application._id && it.packageName == application.package_name - } ?: return false - - /* - * 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) - } - } - fun handleRatingFormat(rating: Double): String? { return if (rating >= MIN_VALID_RATING) { if (rating % 1 == 0.0) { diff --git a/app/src/main/java/foundation/e/apps/install/pkg/PwaManager.kt b/app/src/main/java/foundation/e/apps/install/pkg/PwaManager.kt index d69179139..8c30b945c 100644 --- a/app/src/main/java/foundation/e/apps/install/pkg/PwaManager.kt +++ b/app/src/main/java/foundation/e/apps/install/pkg/PwaManager.kt @@ -119,8 +119,7 @@ class PwaManager @Inject constructor( * Used for periodic status polling in Compose search to mirror legacy status detection. */ fun getInstalledPwaUrls(): Set { - val installed = mutableSetOf() - context.contentResolver.query( + return context.contentResolver.query( PWA_PLAYER.toUri(), arrayOf("url"), null, @@ -128,16 +127,18 @@ class PwaManager @Inject constructor( 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) - } + if (urlIndex == -1) { + return@use emptySet() + } + val installed = mutableSetOf() + while (cursor.moveToNext()) { + val url = cursor.getString(urlIndex) + if (!url.isNullOrBlank()) { + installed.add(url) } } - } - return installed + installed + } ?: emptySet() } suspend fun installPWAApp(appInstall: AppInstall) { diff --git a/app/src/main/java/foundation/e/apps/ui/compose/components/SearchResultListItem.kt b/app/src/main/java/foundation/e/apps/ui/compose/components/SearchResultListItem.kt index 6b8e78e11..c764aadcf 100644 --- a/app/src/main/java/foundation/e/apps/ui/compose/components/SearchResultListItem.kt +++ b/app/src/main/java/foundation/e/apps/ui/compose/components/SearchResultListItem.kt @@ -284,8 +284,6 @@ private fun PrimaryActionArea( val buttonContent: @Composable () -> Unit = { val showSpinner = uiState.isInProgress && uiState.label.isBlank() if (showSpinner) { - val indicatorColor = - if (uiState.isFilledStyle) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onPrimary CircularProgressIndicator( modifier = Modifier .size(16.dp) @@ -294,8 +292,6 @@ private fun PrimaryActionArea( color = labelTextColor, ) } else { - val textColor = - if (uiState.isFilledStyle) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface Text( text = uiState.label, maxLines = 1, diff --git a/app/src/main/java/foundation/e/apps/ui/compose/components/SearchResultsContent.kt b/app/src/main/java/foundation/e/apps/ui/compose/components/SearchResultsContent.kt index 07ece0624..38c9acafd 100644 --- a/app/src/main/java/foundation/e/apps/ui/compose/components/SearchResultsContent.kt +++ b/app/src/main/java/foundation/e/apps/ui/compose/components/SearchResultsContent.kt @@ -47,7 +47,6 @@ import androidx.paging.compose.LazyPagingItems import foundation.e.apps.R import foundation.e.apps.data.application.data.Application 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 @@ -470,7 +469,7 @@ private fun Application.toSearchResultUiState(buttonState: InstallButtonState): isPrivacyLoading = false, primaryAction = PrimaryActionUiState( label = buttonState.label.text - ?: buttonState.progressPercentText + ?: buttonState.progressPercentText ?: buttonState.label.resId?.let { stringResource(id = it) } ?: "", enabled = buttonState.enabled, @@ -485,44 +484,6 @@ private fun Application.toSearchResultUiState(buttonState: InstallButtonState): ) } -@Composable -private fun resolvePrimaryActionState(application: Application): PrimaryActionUiState { - val label = when (application.status) { - Status.INSTALLED -> stringResource(id = R.string.open) - Status.UPDATABLE -> stringResource(id = R.string.update) - Status.INSTALLING -> stringResource(id = R.string.installing) - Status.DOWNLOADING, Status.DOWNLOADED, Status.QUEUED, Status.AWAITING -> stringResource(id = R.string.cancel) - Status.INSTALLATION_ISSUE -> stringResource(id = R.string.retry) - Status.PURCHASE_NEEDED -> application.price.ifBlank { stringResource(id = R.string.install) } - Status.BLOCKED -> stringResource(id = R.string.install) - Status.UNAVAILABLE -> { - if (!application.isFree && !application.isPurchased) { - application.price.ifBlank { stringResource(id = R.string.install) } - } else { - stringResource(id = R.string.install) - } - } - } - - val isInProgress = when (application.status) { - Status.INSTALLING, Status.DOWNLOADING, Status.DOWNLOADED, Status.QUEUED, Status.AWAITING -> true - else -> false - } - - val isEnabled = when (application.status) { - Status.INSTALLING -> false - else -> true - } - - return PrimaryActionUiState( - label = label, - enabled = isEnabled, - isInProgress = isInProgress, - isFilledStyle = true, - showMore = false, - ) -} - internal object SearchResultsContentTestTags { const val REFRESH_LOADER = "search_results_refresh_loader" const val APPEND_LOADER = "search_results_append_loader" diff --git a/app/src/main/java/foundation/e/apps/ui/compose/screens/SearchScreen.kt b/app/src/main/java/foundation/e/apps/ui/compose/screens/SearchScreen.kt index 5f0ff5405..38cefd2b2 100644 --- a/app/src/main/java/foundation/e/apps/ui/compose/screens/SearchScreen.kt +++ b/app/src/main/java/foundation/e/apps/ui/compose/screens/SearchScreen.kt @@ -68,7 +68,6 @@ fun SearchScreen( getScrollPosition: (SearchTabType) -> ScrollPosition? = { null }, onScrollPositionChange: (SearchTabType, Int, Int) -> Unit = { _, _, _ -> }, onResultClick: (Application) -> Unit = {}, - onPrimaryActionClick: (Application) -> Unit = {}, onShowMoreClick: (Application) -> Unit = {}, onPrivacyClick: (Application) -> Unit = {}, onPrimaryAction: (Application, InstallButtonAction) -> Unit = { _, _ -> }, diff --git a/app/src/main/java/foundation/e/apps/ui/compose/state/InstallButtonStateMapper.kt b/app/src/main/java/foundation/e/apps/ui/compose/state/InstallButtonStateMapper.kt index 3f000f345..8095fdcbe 100644 --- a/app/src/main/java/foundation/e/apps/ui/compose/state/InstallButtonStateMapper.kt +++ b/app/src/main/java/foundation/e/apps/ui/compose/state/InstallButtonStateMapper.kt @@ -42,8 +42,10 @@ data class InstallButtonState( val rawStatus: Status = Status.UNAVAILABLE, ) { fun isInProgress(): Boolean { - return showProgressBar || actionIntent == InstallButtonAction.CancelDownload || - rawStatus in Status.downloadStatuses || rawStatus == Status.INSTALLING + return showProgressBar || + actionIntent == InstallButtonAction.CancelDownload || + rawStatus in Status.downloadStatuses || + rawStatus == Status.INSTALLING } } @@ -82,7 +84,16 @@ enum class InstallDialogType { * Lightweight status marker useful for tests and debugging to assert mapping branch taken. */ enum class StatusTag { - Installed, Updatable, UnavailableFree, UnavailablePaid, UnavailableUnsupported, Downloading, Installing, Blocked, InstallationIssue, Unknown, + Installed, + Updatable, + UnavailableFree, + UnavailablePaid, + UnavailableUnsupported, + Downloading, + Installing, + Blocked, + InstallationIssue, + Unknown, } /* @@ -99,6 +110,7 @@ sealed class PurchaseState { * Map raw application + contextual signals into a single button state. * Keep pure: no side effects; callers handle actions. */ +@Suppress("CyclomaticComplexMethod", "LongMethod", "LongParameterList") fun mapAppToInstallState( app: Application, user: User, @@ -111,7 +123,7 @@ fun mapAppToInstallState( overrideStatus: Status? = null, ): InstallButtonState { val status = overrideStatus ?: app.status - val percentLabel = progressPercent?.takeIf { it in 0..100 }?.let { "$it%" } + val percentLabel = progressPercent?.takeIf { it in 0..PERCENTAGE_MAX }?.let { "$it%" } return when (status) { Status.INSTALLED -> InstallButtonState( @@ -201,7 +213,8 @@ fun mapAppToInstallState( Status.QUEUED, Status.AWAITING, Status.DOWNLOADING, Status.DOWNLOADED -> InstallButtonState( label = ButtonLabel( - resId = if (percentLabel == null) R.string.cancel else null, text = percentLabel + resId = if (percentLabel == null) R.string.cancel else null, + text = percentLabel, ), progressPercentText = percentLabel, enabled = true, @@ -260,6 +273,8 @@ fun mapAppToInstallState( } } +private const val PERCENTAGE_MAX = 100 + private fun buildDefaultBlockedLabel(app: Application): ButtonLabel { val literal = app.price.takeIf { it.isNotBlank() } return if (literal != null) ButtonLabel(text = literal) else ButtonLabel(resId = R.string.install) diff --git a/app/src/main/java/foundation/e/apps/ui/compose/state/InstallStatusReconciler.kt b/app/src/main/java/foundation/e/apps/ui/compose/state/InstallStatusReconciler.kt index d87dccd65..7ff52ff34 100644 --- a/app/src/main/java/foundation/e/apps/ui/compose/state/InstallStatusReconciler.kt +++ b/app/src/main/java/foundation/e/apps/ui/compose/state/InstallStatusReconciler.kt @@ -67,26 +67,37 @@ class InstallStatusReconciler @Inject constructor( val pkg = app.package_name val id = app._id return install.packageName == pkg || - install.id == id || - install.id == pkg + install.id == id || + install.id == pkg } private suspend fun progressPercent( activeDownload: AppInstall, progress: DownloadProgress? ): Int? { - if (progress == null) return null + if (progress == null) { + return null + } val percent = appManagerWrapper.calculateProgress(activeDownload, progress) - if (percent in 0..100) return percent - - // Fallback: compute from the last downloadId emitted by DownloadProgress - val id = progress.downloadId.takeIf { it != -1L } - if (id != null && activeDownload.downloadIdMap.containsKey(id)) { - val total = progress.totalSizeBytes[id] ?: return null - if (total <= 0) return null - val done = progress.bytesDownloadedSoFar[id] ?: 0L - return ((done / total.toDouble()) * 100).toInt().coerceIn(0, 100) + var result: Int? = percent.takeIf { it in 0..PERCENTAGE_MAX } + if (result == null) { + val id = progress.downloadId.takeIf { it != INVALID_DOWNLOAD_ID } + val hasDownloadId = id != null && activeDownload.downloadIdMap.containsKey(id) + if (hasDownloadId) { + val total = progress.totalSizeBytes[id] ?: 0L + if (total > 0) { + val done = progress.bytesDownloadedSoFar[id] ?: 0L + result = ((done / total.toDouble()) * PERCENTAGE_MAX) + .toInt() + .coerceIn(0, PERCENTAGE_MAX) + } + } } - return null + return result + } + + companion object { + private const val PERCENTAGE_MAX = 100 + private const val INVALID_DOWNLOAD_ID = -1L } } diff --git a/app/src/main/java/foundation/e/apps/ui/search/v2/SearchFragmentV2.kt b/app/src/main/java/foundation/e/apps/ui/search/v2/SearchFragmentV2.kt index 2b26fd189..7abb256fe 100644 --- a/app/src/main/java/foundation/e/apps/ui/search/v2/SearchFragmentV2.kt +++ b/app/src/main/java/foundation/e/apps/ui/search/v2/SearchFragmentV2.kt @@ -62,6 +62,7 @@ class SearchFragmentV2 : Fragment(R.layout.fragment_search_v2) { private val appProgressViewModel: AppProgressViewModel by viewModels() private val appInfoFetchViewModel: AppInfoFetchViewModel by viewModels() + @Suppress("LongMethod") override fun onViewCreated(view: View, savedInstanceState: Bundle?) { val composeView = view.findViewById(R.id.composeView) composeView.setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) @@ -193,7 +194,7 @@ class SearchFragmentV2 : Fragment(R.layout.fragment_search_v2) { } InstallButtonAction.ShowBlockedSnackbar -> { - showBlockedSnackbar(app) + showBlockedSnackbar() } InstallButtonAction.NoOp -> Unit @@ -223,7 +224,7 @@ class SearchFragmentV2 : Fragment(R.layout.fragment_search_v2) { } } - private fun showBlockedSnackbar(application: Application) { + private fun showBlockedSnackbar() { val errorMsg = when (mainActivityViewModel.getUser()) { User.ANONYMOUS, User.NO_GOOGLE -> getString(R.string.install_blocked_anonymous) diff --git a/app/src/main/java/foundation/e/apps/ui/search/v2/SearchViewModelV2.kt b/app/src/main/java/foundation/e/apps/ui/search/v2/SearchViewModelV2.kt index d948a763e..1032786bf 100644 --- a/app/src/main/java/foundation/e/apps/ui/search/v2/SearchViewModelV2.kt +++ b/app/src/main/java/foundation/e/apps/ui/search/v2/SearchViewModelV2.kt @@ -88,6 +88,7 @@ data class ScrollPosition( ) @HiltViewModel +@Suppress("LongParameterList") class SearchViewModelV2 @Inject constructor( private val suggestionSource: SuggestionSource, private val appLoungePreference: AppLoungePreference, diff --git a/app/src/test/java/foundation/e/apps/data/install/AppManagerWrapperProgressTest.kt b/app/src/test/java/foundation/e/apps/data/install/AppManagerWrapperProgressTest.kt new file mode 100644 index 000000000..071aeffb8 --- /dev/null +++ b/app/src/test/java/foundation/e/apps/data/install/AppManagerWrapperProgressTest.kt @@ -0,0 +1,88 @@ +/* + * 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.install + +import foundation.e.apps.data.enums.Status +import foundation.e.apps.data.fdroid.FDroidRepository +import foundation.e.apps.data.install.models.AppInstall +import foundation.e.apps.install.download.data.DownloadProgress +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Test + +class AppManagerWrapperProgressTest { + + private val appManager = mockk(relaxed = true) + private val fdroidRepository = mockk(relaxed = true) + private val appManagerWrapper = AppManagerWrapper(appManager, fdroidRepository) + + @Test + fun calculateProgress_emptyDownloadIds_returnsZero() = runTest { + val appInstall = AppInstall( + id = "id", + packageName = "pkg", + status = Status.DOWNLOADING, + downloadIdMap = mutableMapOf(), + ) + val progress = DownloadProgress( + totalSizeBytes = mutableMapOf(1L to 100L), + bytesDownloadedSoFar = mutableMapOf(1L to 50L), + ) + + val percent = appManagerWrapper.calculateProgress(appInstall, progress) + + assertEquals(0, percent) + } + + @Test + fun calculateProgress_zeroTotals_returnsZero() = runTest { + val appInstall = AppInstall( + id = "id", + packageName = "pkg", + status = Status.DOWNLOADING, + downloadIdMap = mutableMapOf(1L to true), + ) + val progress = DownloadProgress( + totalSizeBytes = mutableMapOf(1L to 0L), + bytesDownloadedSoFar = mutableMapOf(1L to 50L), + ) + + val percent = appManagerWrapper.calculateProgress(appInstall, progress) + + assertEquals(0, percent) + } + + @Test + fun calculateProgress_clampsAboveHundred() = runTest { + val appInstall = AppInstall( + id = "id", + packageName = "pkg", + status = Status.DOWNLOADING, + downloadIdMap = mutableMapOf(1L to true, 2L to true), + ) + val progress = DownloadProgress( + totalSizeBytes = mutableMapOf(1L to 100L, 2L to 100L), + bytesDownloadedSoFar = mutableMapOf(1L to 200L, 2L to 150L), + ) + + val percent = appManagerWrapper.calculateProgress(appInstall, progress) + + assertEquals(100, percent) + } +} diff --git a/app/src/test/java/foundation/e/apps/install/pkg/PwaManagerTest.kt b/app/src/test/java/foundation/e/apps/install/pkg/PwaManagerTest.kt new file mode 100644 index 000000000..1f7e799f7 --- /dev/null +++ b/app/src/test/java/foundation/e/apps/install/pkg/PwaManagerTest.kt @@ -0,0 +1,103 @@ +/* + * 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.install.pkg + +import android.content.ContentProvider +import android.content.ContentValues +import android.content.Context +import android.database.Cursor +import android.database.MatrixCursor +import android.net.Uri +import androidx.test.core.app.ApplicationProvider +import io.mockk.mockk +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.shadows.ShadowContentResolver + +@RunWith(RobolectricTestRunner::class) +class PwaManagerTest { + + @Test + fun getInstalledPwaUrls_returns_only_non_blank_urls() { + val cursor = MatrixCursor(arrayOf("url")).apply { + addRow(arrayOf("https://pwa.example/one")) + addRow(arrayOf("")) + addRow(arrayOf(null)) + } + registerProvider(cursor) + val manager = PwaManager(context(), mockk(relaxed = true)) + + val urls = manager.getInstalledPwaUrls() + + assertEquals(setOf("https://pwa.example/one"), urls) + } + + @Test + fun getInstalledPwaUrls_missing_url_column_returns_empty() { + val cursor = MatrixCursor(arrayOf("_id")).apply { + addRow(arrayOf("1")) + } + registerProvider(cursor) + val manager = PwaManager(context(), mockk(relaxed = true)) + + val urls = manager.getInstalledPwaUrls() + + assertTrue(urls.isEmpty()) + } + + private fun context(): Context = ApplicationProvider.getApplicationContext() + + private fun registerProvider(cursor: Cursor) { + ShadowContentResolver.registerProviderInternal( + PWA_AUTHORITY, + TestPwaProvider(cursor) + ) + } + + private class TestPwaProvider(private val cursor: Cursor) : ContentProvider() { + override fun onCreate(): Boolean = true + + override fun query( + uri: Uri, + projection: Array?, + selection: String?, + selectionArgs: Array?, + sortOrder: String? + ): Cursor = cursor + + override fun getType(uri: Uri): String? = null + + override fun insert(uri: Uri, values: ContentValues?): Uri? = null + + override fun delete(uri: Uri, selection: String?, selectionArgs: Array?): Int = 0 + + override fun update( + uri: Uri, + values: ContentValues?, + selection: String?, + selectionArgs: Array? + ): Int = 0 + } + + private companion object { + const val PWA_AUTHORITY = "foundation.e.pwaplayer.provider" + } +} diff --git a/app/src/test/java/foundation/e/apps/ui/compose/state/InstallButtonStateMapperTest.kt b/app/src/test/java/foundation/e/apps/ui/compose/state/InstallButtonStateMapperTest.kt index a6d43c781..15e6d450a 100644 --- a/app/src/test/java/foundation/e/apps/ui/compose/state/InstallButtonStateMapperTest.kt +++ b/app/src/test/java/foundation/e/apps/ui/compose/state/InstallButtonStateMapperTest.kt @@ -137,6 +137,23 @@ class InstallButtonStateMapperTest { assertEquals(StatusTag.UnavailablePaid, state.statusTag) } + @Test + fun unavailable_paid_loading_disables_with_progress() { + val state = mapAppToInstallState( + app = baseApp(Status.UNAVAILABLE, isFree = false, price = "$1.99"), + user = User.GOOGLE, + isAnonymousUser = false, + isUnsupported = false, + faultyResult = null, + purchaseState = PurchaseState.Loading, + progressPercent = null, + isSelfUpdate = false, + ) + assertFalse(state.enabled) + assertTrue(state.showProgressBar) + assertEquals(StatusTag.UnavailablePaid, state.statusTag) + } + @Test fun unavailable_paid_purchased_installs() { val state = mapAppToInstallState( @@ -188,6 +205,22 @@ class InstallButtonStateMapperTest { assertEquals(StatusTag.Downloading, state.statusTag) } + @Test + fun downloading_without_progress_shows_cancel_label() { + val state = mapAppToInstallState( + app = baseApp(Status.DOWNLOADING), + user = User.GOOGLE, + isAnonymousUser = false, + isUnsupported = false, + faultyResult = null, + purchaseState = PurchaseState.Unknown, + progressPercent = null, + isSelfUpdate = false, + ) + assertEquals(R.string.cancel, state.label.resId) + assertEquals(InstallButtonAction.CancelDownload, state.actionIntent) + } + @Test fun installing_disabled() { val state = mapAppToInstallState( diff --git a/app/src/test/java/foundation/e/apps/ui/compose/state/InstallStatusReconcilerTest.kt b/app/src/test/java/foundation/e/apps/ui/compose/state/InstallStatusReconcilerTest.kt new file mode 100644 index 000000000..1f37a9cfa --- /dev/null +++ b/app/src/test/java/foundation/e/apps/ui/compose/state/InstallStatusReconcilerTest.kt @@ -0,0 +1,136 @@ +/* + * 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.ui.compose.state + +import foundation.e.apps.data.application.ApplicationRepository +import foundation.e.apps.data.application.data.Application +import foundation.e.apps.data.enums.Status +import foundation.e.apps.data.install.AppManagerWrapper +import foundation.e.apps.data.install.models.AppInstall +import foundation.e.apps.install.download.data.DownloadProgress +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class InstallStatusReconcilerTest { + + private val applicationRepository = mockk(relaxed = true) + private val appManagerWrapper = mockk(relaxed = true) + private val reconciler = InstallStatusReconciler(applicationRepository, appManagerWrapper) + + @Test + fun reconcile_prefers_active_download_status_and_progress() = runTest { + val app = Application(_id = "id", package_name = "pkg", status = Status.UNAVAILABLE) + val download = AppInstall( + id = "id", + packageName = "pkg", + status = Status.DOWNLOADING, + downloadIdMap = mutableMapOf(1L to true), + ) + val snapshot = StatusSnapshot( + downloads = listOf(download), + installedPackages = emptySet(), + installedPwaUrls = emptySet(), + ) + val progress = DownloadProgress() + coEvery { appManagerWrapper.calculateProgress(download, progress) } returns 42 + + val result = reconciler.reconcile(app, snapshot, progress) + + assertEquals(Status.DOWNLOADING, result.application.status) + assertEquals(42, result.progressPercent) + verify(exactly = 0) { applicationRepository.getFusedAppInstallationStatus(any()) } + } + + @Test + fun reconcile_without_download_falls_back_to_fused_status() = runTest { + val app = Application(_id = "id", package_name = "pkg", status = Status.UNAVAILABLE) + val snapshot = StatusSnapshot( + downloads = emptyList(), + installedPackages = emptySet(), + installedPwaUrls = emptySet(), + ) + every { applicationRepository.getFusedAppInstallationStatus(app) } returns Status.INSTALLED + + val result = reconciler.reconcile(app, snapshot, null) + + assertEquals(Status.INSTALLED, result.application.status) + assertNull(result.progressPercent) + coVerify(exactly = 0) { + appManagerWrapper.calculateProgress(any(), any()) + } + } + + @Test + fun reconcile_falls_back_to_last_download_id_progress() = runTest { + val app = Application(_id = "id", package_name = "pkg", status = Status.UNAVAILABLE) + val download = AppInstall( + id = "id", + packageName = "pkg", + status = Status.DOWNLOADING, + downloadIdMap = mutableMapOf(10L to true), + ) + val snapshot = StatusSnapshot( + downloads = listOf(download), + installedPackages = emptySet(), + installedPwaUrls = emptySet(), + ) + val progress = DownloadProgress( + totalSizeBytes = mutableMapOf(10L to 100L), + bytesDownloadedSoFar = mutableMapOf(10L to 40L), + downloadId = 10L, + ) + coEvery { appManagerWrapper.calculateProgress(download, progress) } returns -1 + + val result = reconciler.reconcile(app, snapshot, progress) + + assertEquals(40, result.progressPercent) + } + + @Test + fun reconcile_missing_fallback_progress_returns_null() = runTest { + val app = Application(_id = "id", package_name = "pkg", status = Status.UNAVAILABLE) + val download = AppInstall( + id = "id", + packageName = "pkg", + status = Status.DOWNLOADING, + downloadIdMap = mutableMapOf(10L to true), + ) + val snapshot = StatusSnapshot( + downloads = listOf(download), + installedPackages = emptySet(), + installedPwaUrls = emptySet(), + ) + val progress = DownloadProgress( + totalSizeBytes = mutableMapOf(10L to 0L), + bytesDownloadedSoFar = mutableMapOf(10L to 40L), + downloadId = 10L, + ) + coEvery { appManagerWrapper.calculateProgress(download, progress) } returns -1 + + val result = reconciler.reconcile(app, snapshot, progress) + + assertNull(result.progressPercent) + } +} 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 new file mode 100644 index 000000000..c4d999741 --- /dev/null +++ b/app/src/test/java/foundation/e/apps/ui/compose/state/InstallStatusStreamTest.kt @@ -0,0 +1,104 @@ +/* + * 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.ui.compose.state + +import android.content.pm.ApplicationInfo +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.lifecycle.MutableLiveData +import foundation.e.apps.data.install.AppManagerWrapper +import foundation.e.apps.data.install.models.AppInstall +import foundation.e.apps.install.pkg.AppLoungePackageManager +import foundation.e.apps.install.pkg.PwaManager +import foundation.e.apps.util.MainCoroutineRule +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.async +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class InstallStatusStreamTest { + + @get:Rule + val mainCoroutineRule = MainCoroutineRule() + + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + + @Test + fun stream_emits_combined_snapshot_immediately() = runTest(mainCoroutineRule.testDispatcher) { + val appManagerWrapper = mockk() + val appLoungePackageManager = mockk() + val pwaManager = mockk() + val download = AppInstall(id = "id", packageName = "pkg") + val downloadsLiveData = MutableLiveData(listOf(download)) + + every { appManagerWrapper.getDownloadLiveList() } returns downloadsLiveData + every { appLoungePackageManager.getAllUserApps() } returns listOf(appInfo("com.example.one")) + every { pwaManager.getInstalledPwaUrls() } returns setOf("https://pwa.example") + + val stream = InstallStatusStream(appManagerWrapper, appLoungePackageManager, pwaManager) + + val snapshot = stream.stream(this, packagePollIntervalMs = 100, pwaPollIntervalMs = 100).first() + + assertEquals(listOf(download), snapshot.downloads) + assertEquals(setOf("com.example.one"), snapshot.installedPackages) + assertEquals(setOf("https://pwa.example"), snapshot.installedPwaUrls) + } + + @Test + fun stream_emits_after_poll_interval() = runTest(mainCoroutineRule.testDispatcher) { + val appManagerWrapper = mockk() + val appLoungePackageManager = mockk() + val pwaManager = mockk() + val downloadsLiveData = MutableLiveData(listOf(AppInstall(id = "id", packageName = "pkg"))) + + every { appManagerWrapper.getDownloadLiveList() } returns downloadsLiveData + every { appLoungePackageManager.getAllUserApps() } returnsMany listOf( + listOf(appInfo("com.example.one")), + listOf(appInfo("com.example.two")), + ) + every { pwaManager.getInstalledPwaUrls() } returns setOf("https://pwa.example") + + val stream = InstallStatusStream(appManagerWrapper, appLoungePackageManager, pwaManager) + val deferred = async { + stream.stream(this, packagePollIntervalMs = 50, pwaPollIntervalMs = 50) + .take(2) + .toList() + } + + advanceTimeBy(50) + runCurrent() + + val snapshots = deferred.await() + assertEquals(setOf("com.example.one"), snapshots[0].installedPackages) + assertEquals(setOf("com.example.two"), snapshots[1].installedPackages) + } + + private fun appInfo(packageName: String) = ApplicationInfo().apply { + this.packageName = packageName + } +} diff --git a/app/src/test/java/foundation/e/apps/ui/search/v2/SearchViewModelV2Test.kt b/app/src/test/java/foundation/e/apps/ui/search/v2/SearchViewModelV2Test.kt index b32baa8bd..fcf82c752 100644 --- a/app/src/test/java/foundation/e/apps/ui/search/v2/SearchViewModelV2Test.kt +++ b/app/src/test/java/foundation/e/apps/ui/search/v2/SearchViewModelV2Test.kt @@ -29,6 +29,7 @@ import foundation.e.apps.data.cleanapk.CleanApkRetrofit import foundation.e.apps.data.cleanapk.repositories.CleanApkAppsRepository import foundation.e.apps.data.cleanapk.repositories.CleanApkPwaRepository import foundation.e.apps.data.enums.Source +import foundation.e.apps.data.enums.Status import foundation.e.apps.data.playstore.PlayStoreRepository import foundation.e.apps.data.preference.AppLoungePreference import foundation.e.apps.data.search.CleanApkSearchParams @@ -459,6 +460,66 @@ class SearchViewModelV2Test { assertNull(viewModel.getScrollPosition(SearchTabType.OPEN_SOURCE)) } + @Test + fun `progress percent keys by package name`() = runTest { + val app = Application( + _id = "id", + package_name = "com.example.app", + status = Status.DOWNLOADING, + ) + every { searchPagingRepository.cleanApkSearch(any()) } returns flowOf(PagingData.from(listOf(app))) + coEvery { installStatusReconciler.reconcile(any(), any(), any()) } returns + InstallStatusReconciler.Result(app, 42) + + viewModel.onSearchSubmitted("apps") + collectApplications(viewModel.fossPagingFlow.first()) + + assertEquals(42, viewModel.progressPercentFor(app)) + } + + @Test + fun `null progress removes existing entry`() = runTest { + val appWithProgress = Application( + _id = "id", + package_name = "com.example.app", + status = Status.DOWNLOADING, + ) + val appWithoutProgress = Application( + _id = "id-two", + package_name = "com.example.app", + status = Status.DOWNLOADING, + ) + every { searchPagingRepository.cleanApkSearch(any()) } returns flowOf( + PagingData.from(listOf(appWithProgress, appWithoutProgress)) + ) + coEvery { installStatusReconciler.reconcile(any(), any(), any()) } returnsMany listOf( + InstallStatusReconciler.Result(appWithProgress, 12), + InstallStatusReconciler.Result(appWithoutProgress, null), + ) + + viewModel.onSearchSubmitted("apps") + collectApplications(viewModel.fossPagingFlow.first()) + + assertNull(viewModel.progressPercentFor(appWithProgress)) + } + + @Test + fun `status map falls back to id when package missing`() = runTest { + val app = Application( + _id = "id-missing", + package_name = "", + status = Status.UPDATABLE, + ) + every { searchPagingRepository.cleanApkSearch(any()) } returns flowOf(PagingData.from(listOf(app))) + coEvery { installStatusReconciler.reconcile(any(), any(), any()) } returns + InstallStatusReconciler.Result(app, null) + + viewModel.onSearchSubmitted("apps") + collectApplications(viewModel.fossPagingFlow.first()) + + assertEquals(Status.UPDATABLE, viewModel.statusFor(app)) + } + private fun advanceDebounce() { mainCoroutineRule.testDispatcher.scheduler.advanceTimeBy(DEBOUNCE_MS) mainCoroutineRule.testDispatcher.scheduler.runCurrent() -- GitLab From 5b0e9cd63fe768fde842fa36dd4383cd5a2f9ce4 Mon Sep 17 00:00:00 2001 From: Fahim Masud Choudhury Date: Wed, 28 Jan 2026 12:30:16 +0600 Subject: [PATCH 03/17] refactor: move InstallButtonState UI contract to a separate file --- .../ui/compose/state/InstallButtonState.kt | 103 ++++++++++++++++++ .../compose/state/InstallButtonStateMapper.kt | 82 -------------- 2 files changed, 103 insertions(+), 82 deletions(-) create mode 100644 app/src/main/java/foundation/e/apps/ui/compose/state/InstallButtonState.kt diff --git a/app/src/main/java/foundation/e/apps/ui/compose/state/InstallButtonState.kt b/app/src/main/java/foundation/e/apps/ui/compose/state/InstallButtonState.kt new file mode 100644 index 000000000..878a8ca60 --- /dev/null +++ b/app/src/main/java/foundation/e/apps/ui/compose/state/InstallButtonState.kt @@ -0,0 +1,103 @@ +/* + * 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.ui.compose.state + +import androidx.annotation.StringRes +import foundation.e.apps.data.enums.Status + +/* + * Central UI contract for the primary action button in search results. + * UI layers render purely from this state; business logic must not leak into Composables. + */ +data class InstallButtonState( + val label: ButtonLabel = ButtonLabel(), + val enabled: Boolean = true, + val style: InstallButtonStyle = InstallButtonStyle.AccentOutline, + val showProgressBar: Boolean = false, + val progressPercentText: String? = null, + val actionIntent: InstallButtonAction = InstallButtonAction.NoOp, + @StringRes val snackbarMessageId: Int? = null, + val dialogType: InstallDialogType? = null, + val statusTag: StatusTag = StatusTag.Unknown, + val rawStatus: Status = Status.UNAVAILABLE, +) { + fun isInProgress(): Boolean { + return showProgressBar || + actionIntent == InstallButtonAction.CancelDownload || + rawStatus in Status.downloadStatuses || + rawStatus == Status.INSTALLING + } +} + +/* + * UI label that can be backed by a string resource or a literal string (e.g., price). + */ +data class ButtonLabel( + @StringRes val resId: Int? = null, + val text: String? = null, +) + +/* + * Visual treatment tokens mapped to Compose theme in the UI layer. + */ +enum class InstallButtonStyle { + AccentFill, // Accent background, white text (matches installed/updatable legacy state) + AccentOutline, // Transparent background, accent stroke + text + Disabled, // Light-grey stroke + text +} + +/* + * Intent describing the side-effect the UI host should execute on click. + */ +enum class InstallButtonAction { + Install, CancelDownload, OpenAppOrPwa, UpdateSelfConfirm, ShowPaidDialog, ShowBlockedSnackbar, NoOp, +} + +/* + * Dialog variants initiated from the button state when action requires confirmation. + */ +enum class InstallDialogType { + SelfUpdateConfirmation, PaidAppDialog, +} + +/* + * Lightweight status marker useful for tests and debugging to assert mapping branch taken. + */ +enum class StatusTag { + Installed, + Updatable, + UnavailableFree, + UnavailablePaid, + UnavailableUnsupported, + Downloading, + Installing, + Blocked, + InstallationIssue, + Unknown, +} + +/* + * Purchase resolution states, mirroring legacy branching. + */ +sealed class PurchaseState { + object Unknown : PurchaseState() + object Loading : PurchaseState() + object Purchased : PurchaseState() + object NotPurchased : PurchaseState() +} diff --git a/app/src/main/java/foundation/e/apps/ui/compose/state/InstallButtonStateMapper.kt b/app/src/main/java/foundation/e/apps/ui/compose/state/InstallButtonStateMapper.kt index 8095fdcbe..19eac8d70 100644 --- a/app/src/main/java/foundation/e/apps/ui/compose/state/InstallButtonStateMapper.kt +++ b/app/src/main/java/foundation/e/apps/ui/compose/state/InstallButtonStateMapper.kt @@ -18,94 +18,12 @@ package foundation.e.apps.ui.compose.state -import androidx.annotation.StringRes import foundation.e.apps.R import foundation.e.apps.data.application.data.Application import foundation.e.apps.data.enums.Status import foundation.e.apps.data.enums.User import foundation.e.apps.install.pkg.InstallerService -/* - * Central UI contract for the primary action button in search results. - * UI layers render purely from this state; business logic must not leak into Composables. - */ -data class InstallButtonState( - val label: ButtonLabel = ButtonLabel(), - val enabled: Boolean = true, - val style: InstallButtonStyle = InstallButtonStyle.AccentOutline, - val showProgressBar: Boolean = false, - val progressPercentText: String? = null, - val actionIntent: InstallButtonAction = InstallButtonAction.NoOp, - @StringRes val snackbarMessageId: Int? = null, - val dialogType: InstallDialogType? = null, - val statusTag: StatusTag = StatusTag.Unknown, - val rawStatus: Status = Status.UNAVAILABLE, -) { - fun isInProgress(): Boolean { - return showProgressBar || - actionIntent == InstallButtonAction.CancelDownload || - rawStatus in Status.downloadStatuses || - rawStatus == Status.INSTALLING - } -} - -/* - * UI label that can be backed by a string resource or a literal string (e.g., price). - */ -data class ButtonLabel( - @StringRes val resId: Int? = null, - val text: String? = null, -) - -/* - * Visual treatment tokens mapped to Compose theme in the UI layer. - */ -enum class InstallButtonStyle { - AccentFill, // Accent background, white text (matches installed/updatable legacy state) - AccentOutline, // Transparent background, accent stroke + text - Disabled, // Light-grey stroke + text -} - -/* - * Intent describing the side-effect the UI host should execute on click. - */ -enum class InstallButtonAction { - Install, CancelDownload, OpenAppOrPwa, UpdateSelfConfirm, ShowPaidDialog, ShowBlockedSnackbar, NoOp, -} - -/* - * Dialog variants initiated from the button state when action requires confirmation. - */ -enum class InstallDialogType { - SelfUpdateConfirmation, PaidAppDialog, -} - -/* - * Lightweight status marker useful for tests and debugging to assert mapping branch taken. - */ -enum class StatusTag { - Installed, - Updatable, - UnavailableFree, - UnavailablePaid, - UnavailableUnsupported, - Downloading, - Installing, - Blocked, - InstallationIssue, - Unknown, -} - -/* - * Purchase resolution states, mirroring legacy branching. - */ -sealed class PurchaseState { - object Unknown : PurchaseState() - object Loading : PurchaseState() - object Purchased : PurchaseState() - object NotPurchased : PurchaseState() -} - /* * Map raw application + contextual signals into a single button state. * Keep pure: no side effects; callers handle actions. -- GitLab From df77573914e6d754f3d82de11432034c8031a79d Mon Sep 17 00:00:00 2001 From: Fahim Masud Choudhury Date: Wed, 7 Jan 2026 21:58:17 +0600 Subject: [PATCH 04/17] chore: enforce fresh paging per submit to avoid stale results on repeat queries --- .../components/SearchResultsContent.kt | 300 +++++++++++++----- .../components/search/SearchErrorState.kt | 36 ++- .../e/apps/ui/search/v2/SearchViewModelV2.kt | 21 +- 3 files changed, 264 insertions(+), 93 deletions(-) diff --git a/app/src/main/java/foundation/e/apps/ui/compose/components/SearchResultsContent.kt b/app/src/main/java/foundation/e/apps/ui/compose/components/SearchResultsContent.kt index 38c9acafd..154b4af3b 100644 --- a/app/src/main/java/foundation/e/apps/ui/compose/components/SearchResultsContent.kt +++ b/app/src/main/java/foundation/e/apps/ui/compose/components/SearchResultsContent.kt @@ -33,6 +33,8 @@ import androidx.compose.material3.CircularProgressIndicator import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.saveable.rememberSaveable @@ -77,86 +79,86 @@ fun SearchResultsContent( onPrivacyClick: (Application) -> Unit = {}, installButtonStateProvider: (Application) -> InstallButtonState, ) { - if (tabs.isEmpty() || selectedTab !in tabs) { - return - } - - val coroutineScope = rememberCoroutineScope() - val selectedIndex = tabs.indexOf(selectedTab).coerceAtLeast(0) - val pagerState = rememberPagerState( - initialPage = selectedIndex, - pageCount = { tabs.size }, - ) - val currentOnTabSelect = rememberUpdatedState(onTabSelect) - val currentSelectedTab = rememberUpdatedState(selectedTab) - - LaunchedEffect(tabs, selectedTab) { - val newIndex = tabs.indexOf(selectedTab).coerceAtLeast(0) - if (newIndex in 0 until pagerState.pageCount && pagerState.currentPage != newIndex) { - pagerState.scrollToPage(newIndex) + when { + tabs.isEmpty() || selectedTab !in tabs -> { + return } - } - LaunchedEffect(pagerState.currentPage, tabs) { - tabs.getOrNull(pagerState.currentPage)?.let { tab -> - if (tab != currentSelectedTab.value) { - currentOnTabSelect.value(tab) - } + // Don't show tabs when a single source is checked in the Settings screen + tabs.size == 1 -> { + SearchTabPage( + tab = selectedTab, + fossItems = fossItems, + pwaItems = pwaItems, + playStoreItems = playStoreItems, + searchVersion = searchVersion, + getScrollPosition = getScrollPosition, + onScrollPositionChange = onScrollPositionChange, + onResultClick = onResultClick, + onPrimaryActionClick = onPrimaryActionClick, + onShowMoreClick = onShowMoreClick, + onPrivacyClick = onPrivacyClick, + installButtonStateProvider = installButtonStateProvider, + modifier = modifier.fillMaxSize(), + ) + return } - } - Column( - modifier = modifier.fillMaxSize(), - ) { - SearchTabs( - tabs = tabs, - selectedIndex = pagerState.currentPage, - onTabSelect = { tab, index -> - coroutineScope.launch { - pagerState.animateScrollToPage(index) + else -> { + val coroutineScope = rememberCoroutineScope() + val selectedIndex = tabs.indexOf(selectedTab).coerceAtLeast(0) + val pagerState = rememberPagerState( + initialPage = selectedIndex, + pageCount = { tabs.size }, + ) + val currentOnTabSelect = rememberUpdatedState(onTabSelect) + val currentSelectedTab = rememberUpdatedState(selectedTab) + + LaunchedEffect(tabs, selectedTab) { + val newIndex = tabs.indexOf(selectedTab).coerceAtLeast(0) + if (newIndex in 0 until pagerState.pageCount && pagerState.currentPage != newIndex) { + pagerState.scrollToPage(newIndex) } - onTabSelect(tab) - }, - modifier = Modifier.fillMaxWidth(), - ) - HorizontalPager( - state = pagerState, - modifier = Modifier - .fillMaxSize() - .padding(top = 16.dp), - ) { page -> - val tab = tabs[page] - - val items = when (tab) { - SearchTabType.OPEN_SOURCE -> fossItems - SearchTabType.PWA -> pwaItems - else -> null } - when (tab) { - SearchTabType.OPEN_SOURCE, SearchTabType.PWA -> { - PagingSearchResultList( - items = items, - searchVersion = searchVersion, - tab = tab, - getScrollPosition = getScrollPosition, - onScrollPositionChange = onScrollPositionChange, - onItemClick = onResultClick, - onPrimaryActionClick = onPrimaryActionClick, - onShowMoreClick = onShowMoreClick, - onPrivacyClick = onPrivacyClick, - installButtonStateProvider = installButtonStateProvider, - modifier = Modifier.fillMaxSize(), - ) + LaunchedEffect(pagerState.currentPage, tabs) { + tabs.getOrNull(pagerState.currentPage)?.let { tab -> + if (tab != currentSelectedTab.value) { + currentOnTabSelect.value(tab) + } } + } - SearchTabType.COMMON_APPS -> { - PagingPlayStoreResultList( - items = playStoreItems, + Column( + modifier = modifier.fillMaxSize(), + ) { + SearchTabs( + tabs = tabs, + selectedIndex = pagerState.currentPage, + onTabSelect = { tab, index -> + coroutineScope.launch { + pagerState.animateScrollToPage(index) + } + onTabSelect(tab) + }, + modifier = Modifier.fillMaxWidth(), + ) + HorizontalPager( + state = pagerState, + modifier = Modifier + .fillMaxSize() + .padding(top = 16.dp), + ) { page -> + val tab = tabs[page] + SearchTabPage( + tab = tab, + fossItems = fossItems, + pwaItems = pwaItems, + playStoreItems = playStoreItems, searchVersion = searchVersion, getScrollPosition = getScrollPosition, onScrollPositionChange = onScrollPositionChange, - onItemClick = onResultClick, + onResultClick = onResultClick, onPrimaryActionClick = onPrimaryActionClick, onShowMoreClick = onShowMoreClick, onPrivacyClick = onPrivacyClick, @@ -169,6 +171,72 @@ fun SearchResultsContent( } } +@Composable +private fun SearchTabPage( + tab: SearchTabType, + fossItems: LazyPagingItems?, + pwaItems: LazyPagingItems?, + playStoreItems: LazyPagingItems?, + searchVersion: Int, + getScrollPosition: (SearchTabType) -> ScrollPosition?, + onScrollPositionChange: (SearchTabType, Int, Int) -> Unit, + onResultClick: (Application) -> Unit, + onPrimaryActionClick: (Application, InstallButtonAction) -> Unit, + onShowMoreClick: (Application) -> Unit, + onPrivacyClick: (Application) -> Unit, + installButtonStateProvider: (Application) -> InstallButtonState, + modifier: Modifier = Modifier, +) { + when (tab) { + SearchTabType.OPEN_SOURCE -> { + PagingSearchResultList( + items = fossItems, + searchVersion = searchVersion, + tab = tab, + getScrollPosition = getScrollPosition, + onScrollPositionChange = onScrollPositionChange, + onItemClick = onResultClick, + onPrimaryActionClick = onPrimaryActionClick, + onShowMoreClick = onShowMoreClick, + onPrivacyClick = onPrivacyClick, + installButtonStateProvider = installButtonStateProvider, + modifier = modifier, + ) + } + + SearchTabType.PWA -> { + PagingSearchResultList( + items = pwaItems, + searchVersion = searchVersion, + tab = tab, + getScrollPosition = getScrollPosition, + onScrollPositionChange = onScrollPositionChange, + onItemClick = onResultClick, + onPrimaryActionClick = onPrimaryActionClick, + onShowMoreClick = onShowMoreClick, + onPrivacyClick = onPrivacyClick, + installButtonStateProvider = installButtonStateProvider, + modifier = modifier, + ) + } + + SearchTabType.COMMON_APPS -> { + PagingPlayStoreResultList( + items = playStoreItems, + searchVersion = searchVersion, + getScrollPosition = getScrollPosition, + onScrollPositionChange = onScrollPositionChange, + onItemClick = onResultClick, + onPrimaryActionClick = onPrimaryActionClick, + onShowMoreClick = onShowMoreClick, + onPrivacyClick = onPrivacyClick, + installButtonStateProvider = installButtonStateProvider, + modifier = modifier, + ) + } + } +} + @Composable private fun PagingPlayStoreResultList( items: LazyPagingItems?, @@ -207,14 +275,33 @@ private fun PagingPlayStoreResultList( val loadState = lazyItems.loadState - val errorState = loadState.refresh as? LoadState.Error - ?: loadState.prepend as? LoadState.Error - ?: loadState.append as? LoadState.Error + val refreshState = loadState.refresh + val refreshError = refreshState as? LoadState.Error + val appendError = loadState.append as? LoadState.Error + val prependError = loadState.prepend as? LoadState.Error - val isRefreshing = loadState.refresh is LoadState.Loading + val isRefreshing = refreshState is LoadState.Loading val isAppending = loadState.append is LoadState.Loading - val isError = errorState != null - val isEmpty = !isRefreshing && !isError && lazyItems.itemCount == 0 + + val hasLoadedCurrentQuery = remember(searchVersion) { mutableStateOf(false) } + + LaunchedEffect(searchVersion, refreshState, lazyItems.itemCount) { + if (refreshState is LoadState.NotLoading && lazyItems.itemCount > 0) { + hasLoadedCurrentQuery.value = true + } + if (refreshState is LoadState.Loading && lazyItems.itemCount == 0) { + hasLoadedCurrentQuery.value = false + } + } + + val initialLoadError = refreshError != null && !hasLoadedCurrentQuery.value + val showFooterError = hasLoadedCurrentQuery.value && listOf( + refreshError, + appendError, + prependError + ).any { it != null } + val isEmpty = + !isRefreshing && refreshError == null && appendError == null && prependError == null && lazyItems.itemCount == 0 Box(modifier = modifier) { when { @@ -222,10 +309,11 @@ private fun PagingPlayStoreResultList( SearchShimmerList() } - isError -> { + initialLoadError -> { SearchErrorState( onRetry = { lazyItems.retry() }, - modifier = Modifier.fillMaxSize() + modifier = Modifier.fillMaxSize(), + fullScreen = true, ) } @@ -287,6 +375,18 @@ private fun PagingPlayStoreResultList( } } } + + if (showFooterError) { + item(key = "error_footer_play_store") { + SearchErrorState( + onRetry = { lazyItems.retry() }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + fullScreen = false, + ) + } + } } } } @@ -328,14 +428,33 @@ private fun PagingSearchResultList( val loadState = lazyItems.loadState - val errorState = loadState.refresh as? LoadState.Error - ?: loadState.prepend as? LoadState.Error - ?: loadState.append as? LoadState.Error + val refreshState = loadState.refresh + val refreshError = refreshState as? LoadState.Error + val appendError = loadState.append as? LoadState.Error + val prependError = loadState.prepend as? LoadState.Error - val isRefreshing = loadState.refresh is LoadState.Loading + val isRefreshing = refreshState is LoadState.Loading val isAppending = loadState.append is LoadState.Loading - val isError = errorState != null - val isEmpty = !isRefreshing && !isError && lazyItems.itemCount == 0 + + val hasLoadedCurrentQuery = remember(searchVersion) { mutableStateOf(false) } + + LaunchedEffect(searchVersion, refreshState, lazyItems.itemCount) { + if (refreshState is LoadState.NotLoading && lazyItems.itemCount > 0) { + hasLoadedCurrentQuery.value = true + } + if (refreshState is LoadState.Loading && lazyItems.itemCount == 0) { + hasLoadedCurrentQuery.value = false + } + } + + val initialLoadError = refreshError != null && !hasLoadedCurrentQuery.value + val showFooterError = hasLoadedCurrentQuery.value && listOf( + refreshError, + appendError, + prependError + ).any { it != null } + val isEmpty = + !isRefreshing && refreshError == null && appendError == null && prependError == null && lazyItems.itemCount == 0 Box(modifier = modifier) { when { @@ -345,10 +464,11 @@ private fun PagingSearchResultList( ) } - isError -> { + initialLoadError -> { SearchErrorState( onRetry = { lazyItems.retry() }, - modifier = Modifier.fillMaxSize() + modifier = Modifier.fillMaxSize(), + fullScreen = true, ) } @@ -415,6 +535,18 @@ private fun PagingSearchResultList( } } } + + if (showFooterError) { + item(key = "error_footer") { + SearchErrorState( + onRetry = { lazyItems.retry() }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + fullScreen = false, + ) + } + } } } } diff --git a/app/src/main/java/foundation/e/apps/ui/compose/components/search/SearchErrorState.kt b/app/src/main/java/foundation/e/apps/ui/compose/components/search/SearchErrorState.kt index a8ad4969d..964253ab5 100644 --- a/app/src/main/java/foundation/e/apps/ui/compose/components/search/SearchErrorState.kt +++ b/app/src/main/java/foundation/e/apps/ui/compose/components/search/SearchErrorState.kt @@ -21,6 +21,7 @@ package foundation.e.apps.ui.compose.components.search import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -31,22 +32,37 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import foundation.e.apps.R +import foundation.e.apps.ui.compose.theme.AppTheme @Composable fun SearchErrorState( onRetry: () -> Unit, modifier: Modifier = Modifier, + fullScreen: Boolean = true, ) { + val containerModifier = if (fullScreen) { + modifier.fillMaxSize() + } else { + modifier.fillMaxWidth() + } + + val contentPadding = if (fullScreen) { + PaddingValues(all = 24.dp) + } else { + PaddingValues(horizontal = 16.dp, vertical = 12.dp) + } + Box( - modifier = modifier.fillMaxSize(), + modifier = containerModifier, contentAlignment = Alignment.Center ) { Column( modifier = Modifier .fillMaxWidth() - .padding(24.dp), + .padding(contentPadding), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(12.dp), ) { @@ -61,3 +77,19 @@ fun SearchErrorState( } } } + +@Preview(showBackground = true) +@Composable +private fun SearchErrorStateFullScreenPreview() { + AppTheme { + SearchErrorState(onRetry = {}, fullScreen = true) + } +} + +@Preview(showBackground = true) +@Composable +private fun SearchErrorStateFooterPreview() { + AppTheme { + SearchErrorState(onRetry = {}, fullScreen = false) + } +} diff --git a/app/src/main/java/foundation/e/apps/ui/search/v2/SearchViewModelV2.kt b/app/src/main/java/foundation/e/apps/ui/search/v2/SearchViewModelV2.kt index 1032786bf..8526c1106 100644 --- a/app/src/main/java/foundation/e/apps/ui/search/v2/SearchViewModelV2.kt +++ b/app/src/main/java/foundation/e/apps/ui/search/v2/SearchViewModelV2.kt @@ -113,6 +113,7 @@ class SearchViewModelV2 @Inject constructor( private data class SearchRequest( val query: String, val visibleTabs: List, + val version: Int, ) private val searchRequests = MutableStateFlow(null) @@ -238,7 +239,9 @@ class SearchViewModelV2 @Inject constructor( val selectedTab = _uiState.value.selectedTab?.takeIf { visibleTabs.contains(it) } ?: visibleTabs.firstOrNull() + var nextVersion = _uiState.value.searchVersion + 1 _uiState.update { current -> + nextVersion = current.searchVersion + 1 current.copy( query = trimmedQuery, suggestions = emptyList(), @@ -246,14 +249,15 @@ class SearchViewModelV2 @Inject constructor( availableTabs = visibleTabs, selectedTab = selectedTab, hasSubmittedSearch = visibleTabs.isNotEmpty(), - searchVersion = current.searchVersion + 1, + searchVersion = nextVersion, ) } if (visibleTabs.isNotEmpty()) { searchRequests.value = SearchRequest( query = trimmedQuery, - visibleTabs = visibleTabs + visibleTabs = visibleTabs, + version = nextVersion, ) _scrollPositions.update { emptyMap() } } @@ -284,17 +288,20 @@ class SearchViewModelV2 @Inject constructor( ) } - val currentQuery = _uiState.value.query - val shouldUpdateRequest = _uiState.value.hasSubmittedSearch && currentQuery.isNotBlank() + val currentState = _uiState.value + val currentQuery = currentState.query + val shouldUpdateRequest = currentState.hasSubmittedSearch && currentQuery.isNotBlank() if (shouldUpdateRequest && visibleTabs.isNotEmpty()) { searchRequests.value = SearchRequest( query = currentQuery, - visibleTabs = visibleTabs + visibleTabs = visibleTabs, + version = currentState.searchVersion, ) - } else if (!_uiState.value.hasSubmittedSearch) { + } else if (!currentState.hasSubmittedSearch) { searchRequests.value = SearchRequest( query = "", - visibleTabs = visibleTabs + visibleTabs = visibleTabs, + version = currentState.searchVersion, ) } } -- GitLab From 9c54d2b3ecd85957d49234d601b4254db3268258 Mon Sep 17 00:00:00 2001 From: Fahim Masud Choudhury Date: Mon, 26 Jan 2026 14:06:18 +0600 Subject: [PATCH 05/17] test: ensure test coverage for error handling in search results --- .../components/SearchResultsContentTest.kt | 79 +++++++++++++++++++ .../components/search/SearchErrorStateTest.kt | 74 +++++++++++++++++ .../ui/search/v2/SearchViewModelV2Test.kt | 54 ++++++++++++- 3 files changed, 204 insertions(+), 3 deletions(-) create mode 100644 app/src/androidTest/java/foundation/e/apps/ui/compose/components/search/SearchErrorStateTest.kt diff --git a/app/src/androidTest/java/foundation/e/apps/ui/compose/components/SearchResultsContentTest.kt b/app/src/androidTest/java/foundation/e/apps/ui/compose/components/SearchResultsContentTest.kt index 6be7fd9cc..b673259f9 100644 --- a/app/src/androidTest/java/foundation/e/apps/ui/compose/components/SearchResultsContentTest.kt +++ b/app/src/androidTest/java/foundation/e/apps/ui/compose/components/SearchResultsContentTest.kt @@ -224,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( @@ -242,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( + sourceLoadStates = loadStates( + refresh = LoadState.NotLoading(endOfPaginationReached = true) + ) + ) + val loadingPagingData = PagingData.empty( + 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( diff --git a/app/src/androidTest/java/foundation/e/apps/ui/compose/components/search/SearchErrorStateTest.kt b/app/src/androidTest/java/foundation/e/apps/ui/compose/components/search/SearchErrorStateTest.kt new file mode 100644 index 000000000..8662ae8b3 --- /dev/null +++ b/app/src/androidTest/java/foundation/e/apps/ui/compose/components/search/SearchErrorStateTest.kt @@ -0,0 +1,74 @@ +/* + * 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.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() + + @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() + } +} diff --git a/app/src/test/java/foundation/e/apps/ui/search/v2/SearchViewModelV2Test.kt b/app/src/test/java/foundation/e/apps/ui/search/v2/SearchViewModelV2Test.kt index fcf82c752..41ac57c9e 100644 --- a/app/src/test/java/foundation/e/apps/ui/search/v2/SearchViewModelV2Test.kt +++ b/app/src/test/java/foundation/e/apps/ui/search/v2/SearchViewModelV2Test.kt @@ -354,7 +354,12 @@ class SearchViewModelV2Test { openSourceSelected = false pwaSelected = false buildViewModel() - every { playStorePagingRepository.playStoreSearch(any(), any()) } returns flowOf(PagingData.empty()) + every { + playStorePagingRepository.playStoreSearch( + any(), + any() + ) + } returns flowOf(PagingData.empty()) viewModel.onSearchSubmitted(" android ") viewModel.playStorePagingFlow.first() @@ -449,6 +454,37 @@ class SearchViewModelV2Test { assertEquals(firstVersion + 1, viewModel.uiState.value.searchVersion) } + @Test + fun `blank search submit keeps search version`() = runTest { + viewModel.onSearchSubmitted("first") + val firstVersion = viewModel.uiState.value.searchVersion + + viewModel.onSearchSubmitted(" ") + + assertEquals(firstVersion, viewModel.uiState.value.searchVersion) + } + + @Test + fun `search submit emits new requests for repeated query`() = runTest { + playStoreSelected = true + openSourceSelected = false + pwaSelected = false + buildViewModel() + every { + playStorePagingRepository.playStoreSearch( + any(), + any() + ) + } returns flowOf(PagingData.empty()) + + viewModel.onSearchSubmitted("apps") + viewModel.playStorePagingFlow.first() + viewModel.onSearchSubmitted("apps") + viewModel.playStorePagingFlow.first() + + verify(exactly = 2) { playStorePagingRepository.playStoreSearch("apps", 20) } + } + @Test fun `search submit clears scroll positions`() = runTest { viewModel.updateScrollPosition(SearchTabType.COMMON_APPS, 6, 4) @@ -467,7 +503,13 @@ class SearchViewModelV2Test { package_name = "com.example.app", status = Status.DOWNLOADING, ) - every { searchPagingRepository.cleanApkSearch(any()) } returns flowOf(PagingData.from(listOf(app))) + every { searchPagingRepository.cleanApkSearch(any()) } returns flowOf( + PagingData.from( + listOf( + app + ) + ) + ) coEvery { installStatusReconciler.reconcile(any(), any(), any()) } returns InstallStatusReconciler.Result(app, 42) @@ -510,7 +552,13 @@ class SearchViewModelV2Test { package_name = "", status = Status.UPDATABLE, ) - every { searchPagingRepository.cleanApkSearch(any()) } returns flowOf(PagingData.from(listOf(app))) + every { searchPagingRepository.cleanApkSearch(any()) } returns flowOf( + PagingData.from( + listOf( + app + ) + ) + ) coEvery { installStatusReconciler.reconcile(any(), any(), any()) } returns InstallStatusReconciler.Result(app, null) -- GitLab From 4bbc9166286f9e545df89ac645a19523a80e58a6 Mon Sep 17 00:00:00 2001 From: Fahim Masud Choudhury Date: Wed, 28 Jan 2026 19:00:57 +0600 Subject: [PATCH 06/17] refactor: extract usecases from SearchViewModelV2 to orchestrate cleanApk and PlayStore search --- .../data/search/PlayStoreAppMapperImpl.kt | 37 +++++ .../e/apps/di/SearchPagingModule.kt | 8 + .../search/CleanApkSearchPagingUseCase.kt | 61 +++++++ .../apps/domain/search/PlayStoreAppMapper.kt | 26 +++ .../search/PlayStoreSearchPagingUseCase.kt | 64 ++++++++ .../e/apps/domain/search/SearchRequest.kt | 27 +++ .../e/apps/ui/search/v2/SearchViewModelV2.kt | 96 ++--------- .../search/CleanApkSearchPagingUseCaseTest.kt | 141 ++++++++++++++++ .../PlayStoreSearchPagingUseCaseTest.kt | 154 ++++++++++++++++++ .../ui/search/v2/SearchViewModelV2Test.kt | 26 ++- 10 files changed, 556 insertions(+), 84 deletions(-) create mode 100644 app/src/main/java/foundation/e/apps/data/search/PlayStoreAppMapperImpl.kt create mode 100644 app/src/main/java/foundation/e/apps/domain/search/CleanApkSearchPagingUseCase.kt create mode 100644 app/src/main/java/foundation/e/apps/domain/search/PlayStoreAppMapper.kt create mode 100644 app/src/main/java/foundation/e/apps/domain/search/PlayStoreSearchPagingUseCase.kt create mode 100644 app/src/main/java/foundation/e/apps/domain/search/SearchRequest.kt create mode 100644 app/src/test/java/foundation/e/apps/domain/search/CleanApkSearchPagingUseCaseTest.kt create mode 100644 app/src/test/java/foundation/e/apps/domain/search/PlayStoreSearchPagingUseCaseTest.kt diff --git a/app/src/main/java/foundation/e/apps/data/search/PlayStoreAppMapperImpl.kt b/app/src/main/java/foundation/e/apps/data/search/PlayStoreAppMapperImpl.kt new file mode 100644 index 000000000..647ad1ece --- /dev/null +++ b/app/src/main/java/foundation/e/apps/data/search/PlayStoreAppMapperImpl.kt @@ -0,0 +1,37 @@ +/* + * 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.search + +import android.content.Context +import com.aurora.gplayapi.data.models.App +import dagger.hilt.android.qualifiers.ApplicationContext +import foundation.e.apps.data.application.data.Application +import foundation.e.apps.data.application.utils.toApplication +import foundation.e.apps.domain.search.PlayStoreAppMapper +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class PlayStoreAppMapperImpl @Inject constructor( + @ApplicationContext private val context: Context, +) : PlayStoreAppMapper { + override fun map(app: App): Application { + return app.toApplication(context) + } +} diff --git a/app/src/main/java/foundation/e/apps/di/SearchPagingModule.kt b/app/src/main/java/foundation/e/apps/di/SearchPagingModule.kt index 54d3f2519..d0aa29ffb 100644 --- a/app/src/main/java/foundation/e/apps/di/SearchPagingModule.kt +++ b/app/src/main/java/foundation/e/apps/di/SearchPagingModule.kt @@ -23,9 +23,11 @@ import dagger.Module import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import foundation.e.apps.data.search.CleanApkSearchPagingRepository +import foundation.e.apps.data.search.PlayStoreAppMapperImpl import foundation.e.apps.data.search.PlayStoreWebSearch import foundation.e.apps.data.search.PlayStoreWebSearchImpl import foundation.e.apps.data.search.SearchPagingRepository +import foundation.e.apps.domain.search.PlayStoreAppMapper import javax.inject.Singleton @Module @@ -42,4 +44,10 @@ abstract class SearchPagingModule { abstract fun bindPlayStoreWebSearch( impl: PlayStoreWebSearchImpl ): PlayStoreWebSearch + + @Binds + @Singleton + abstract fun bindPlayStoreAppMapper( + impl: PlayStoreAppMapperImpl + ): PlayStoreAppMapper } diff --git a/app/src/main/java/foundation/e/apps/domain/search/CleanApkSearchPagingUseCase.kt b/app/src/main/java/foundation/e/apps/domain/search/CleanApkSearchPagingUseCase.kt new file mode 100644 index 000000000..5331b7750 --- /dev/null +++ b/app/src/main/java/foundation/e/apps/domain/search/CleanApkSearchPagingUseCase.kt @@ -0,0 +1,61 @@ +/* + * 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.domain.search + +import androidx.paging.PagingData +import foundation.e.apps.data.application.data.Application +import foundation.e.apps.data.search.CleanApkSearchParams +import foundation.e.apps.data.search.SearchPagingRepository +import foundation.e.apps.ui.search.v2.SearchTabType +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.mapLatest +import javax.inject.Inject + +class CleanApkSearchPagingUseCase @Inject constructor( + private val searchPagingRepository: SearchPagingRepository, +) { + @OptIn(ExperimentalCoroutinesApi::class) + operator fun invoke( + requests: Flow, + tab: SearchTabType, + appSource: String, + appType: String, + ): Flow> { + return requests + .filterNotNull() + .mapLatest { request -> + if (!request.visibleTabs.contains(tab) || request.query.isBlank()) { + flowOf(PagingData.empty()) + } else { + searchPagingRepository.cleanApkSearch( + CleanApkSearchParams( + keyword = request.query, + appSource = appSource, + appType = appType, + ) + ) + } + } + .flatMapLatest { it } + } +} diff --git a/app/src/main/java/foundation/e/apps/domain/search/PlayStoreAppMapper.kt b/app/src/main/java/foundation/e/apps/domain/search/PlayStoreAppMapper.kt new file mode 100644 index 000000000..791000605 --- /dev/null +++ b/app/src/main/java/foundation/e/apps/domain/search/PlayStoreAppMapper.kt @@ -0,0 +1,26 @@ +/* + * 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.domain.search + +import com.aurora.gplayapi.data.models.App +import foundation.e.apps.data.application.data.Application + +interface PlayStoreAppMapper { + fun map(app: App): Application +} diff --git a/app/src/main/java/foundation/e/apps/domain/search/PlayStoreSearchPagingUseCase.kt b/app/src/main/java/foundation/e/apps/domain/search/PlayStoreSearchPagingUseCase.kt new file mode 100644 index 000000000..67baab98f --- /dev/null +++ b/app/src/main/java/foundation/e/apps/domain/search/PlayStoreSearchPagingUseCase.kt @@ -0,0 +1,64 @@ +/* + * 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.domain.search + +import androidx.paging.PagingData +import androidx.paging.map +import com.aurora.gplayapi.data.models.App +import foundation.e.apps.data.application.data.Application +import foundation.e.apps.data.search.PlayStorePagingRepository +import foundation.e.apps.ui.search.v2.SearchTabType +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapLatest +import javax.inject.Inject + +class PlayStoreSearchPagingUseCase @Inject constructor( + private val playStorePagingRepository: PlayStorePagingRepository, + private val playStoreAppMapper: PlayStoreAppMapper, +) { + @OptIn(ExperimentalCoroutinesApi::class) + operator fun invoke( + requests: Flow, + pageSize: Int, + tab: SearchTabType = SearchTabType.COMMON_APPS, + ): Flow> { + return requests + .filterNotNull() + .mapLatest { request -> + if (!request.visibleTabs.contains(tab) || request.query.isBlank()) { + flowOf(PagingData.empty()) + } else { + playStorePagingRepository.playStoreSearch( + query = request.query, + pageSize = pageSize, + ).map { pagingData: PagingData -> + pagingData.map { app: App -> + playStoreAppMapper.map(app) + } + } + } + } + .flatMapLatest { it } + } +} diff --git a/app/src/main/java/foundation/e/apps/domain/search/SearchRequest.kt b/app/src/main/java/foundation/e/apps/domain/search/SearchRequest.kt new file mode 100644 index 000000000..fe3a91cda --- /dev/null +++ b/app/src/main/java/foundation/e/apps/domain/search/SearchRequest.kt @@ -0,0 +1,27 @@ +/* + * 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.domain.search + +import foundation.e.apps.ui.search.v2.SearchTabType + +data class SearchRequest( + val query: String, + val visibleTabs: List, + val version: Int, +) diff --git a/app/src/main/java/foundation/e/apps/ui/search/v2/SearchViewModelV2.kt b/app/src/main/java/foundation/e/apps/ui/search/v2/SearchViewModelV2.kt index 8526c1106..bb965f6e0 100644 --- a/app/src/main/java/foundation/e/apps/ui/search/v2/SearchViewModelV2.kt +++ b/app/src/main/java/foundation/e/apps/ui/search/v2/SearchViewModelV2.kt @@ -18,32 +18,28 @@ package foundation.e.apps.ui.search.v2 -import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.paging.PagingData import androidx.paging.cachedIn import androidx.paging.map import dagger.hilt.android.lifecycle.HiltViewModel -import dagger.hilt.android.qualifiers.ApplicationContext import foundation.e.apps.data.Stores import foundation.e.apps.data.application.data.Application -import foundation.e.apps.data.application.utils.toApplication import foundation.e.apps.data.cleanapk.CleanApkRetrofit import foundation.e.apps.data.enums.Source.OPEN_SOURCE import foundation.e.apps.data.enums.Source.PLAY_STORE import foundation.e.apps.data.enums.Source.PWA import foundation.e.apps.data.enums.Status import foundation.e.apps.data.preference.AppLoungePreference -import foundation.e.apps.data.search.CleanApkSearchParams -import foundation.e.apps.data.search.PlayStorePagingRepository -import foundation.e.apps.data.search.SearchPagingRepository import foundation.e.apps.data.search.SuggestionSource +import foundation.e.apps.domain.search.CleanApkSearchPagingUseCase +import foundation.e.apps.domain.search.PlayStoreSearchPagingUseCase +import foundation.e.apps.domain.search.SearchRequest import foundation.e.apps.install.download.data.DownloadProgress import foundation.e.apps.ui.compose.state.InstallStatusReconciler import foundation.e.apps.ui.compose.state.InstallStatusStream import foundation.e.apps.ui.compose.state.StatusSnapshot -import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow @@ -52,10 +48,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject @@ -92,12 +85,11 @@ data class ScrollPosition( class SearchViewModelV2 @Inject constructor( private val suggestionSource: SuggestionSource, private val appLoungePreference: AppLoungePreference, - private val searchPagingRepository: SearchPagingRepository, - private val playStorePagingRepository: PlayStorePagingRepository, + private val cleanApkSearchPagingUseCase: CleanApkSearchPagingUseCase, + private val playStoreSearchPagingUseCase: PlayStoreSearchPagingUseCase, private val stores: Stores, private val installStatusStream: InstallStatusStream, private val installStatusReconciler: InstallStatusReconciler, - @ApplicationContext private val appContext: Context, ) : ViewModel() { private val initialVisibleTabs = resolveVisibleTabs() @@ -110,12 +102,6 @@ class SearchViewModelV2 @Inject constructor( ) val uiState: StateFlow = _uiState.asStateFlow() - private data class SearchRequest( - val query: String, - val visibleTabs: List, - val version: Int, - ) - private val searchRequests = MutableStateFlow(null) private val _scrollPositions = MutableStateFlow>(emptyMap()) private val statusSnapshot = MutableStateFlow(null) @@ -125,19 +111,25 @@ class SearchViewModelV2 @Inject constructor( private val _statusByKey = MutableStateFlow>(emptyMap()) val statusByKey: StateFlow> = _statusByKey.asStateFlow() - val fossPagingFlow = buildCleanApkPagingFlow( + val fossPagingFlow = cleanApkSearchPagingUseCase( + requests = searchRequests, tab = SearchTabType.OPEN_SOURCE, appSource = CleanApkRetrofit.APP_SOURCE_FOSS, - appType = CleanApkRetrofit.APP_TYPE_NATIVE - ).withStatus() + appType = CleanApkRetrofit.APP_TYPE_NATIVE, + ).cachedIn(viewModelScope).withStatus() - val pwaPagingFlow = buildCleanApkPagingFlow( + val pwaPagingFlow = cleanApkSearchPagingUseCase( + requests = searchRequests, tab = SearchTabType.PWA, appSource = CleanApkRetrofit.APP_SOURCE_ANY, - appType = CleanApkRetrofit.APP_TYPE_PWA - ).withStatus() + appType = CleanApkRetrofit.APP_TYPE_PWA, + ).cachedIn(viewModelScope).withStatus() - val playStorePagingFlow = buildPlayStorePagingFlow().withStatus() + val playStorePagingFlow = playStoreSearchPagingUseCase( + requests = searchRequests, + pageSize = DEFAULT_PLAY_STORE_PAGE_SIZE, + tab = SearchTabType.COMMON_APPS, + ).cachedIn(viewModelScope).withStatus() private var suggestionJob: Job? = null @@ -337,58 +329,6 @@ class SearchViewModelV2 @Inject constructor( } } - @OptIn(ExperimentalCoroutinesApi::class) - private fun buildCleanApkPagingFlow( - tab: SearchTabType, - appSource: String, - appType: String - ) = searchRequests - .filterNotNull() - .mapLatest { request -> - if (!request.visibleTabs.contains(tab)) { - flowOf(PagingData.empty()) - } else if (request.query.isBlank()) { - flowOf(PagingData.empty()) - } else { - searchPagingRepository.cleanApkSearch( - CleanApkSearchParams( - keyword = request.query, - appSource = appSource, - appType = appType - ) - ).map { pagingData -> - pagingData.map { app -> - reconcile(app) - } - } - } - } - .flatMapLatest { it } - .cachedIn(viewModelScope) - - @OptIn(ExperimentalCoroutinesApi::class) - private fun buildPlayStorePagingFlow() = - searchRequests - .filterNotNull() - .mapLatest { request -> - if (!request.visibleTabs.contains(SearchTabType.COMMON_APPS)) { - flowOf(PagingData.empty()) - } else if (request.query.isBlank()) { - flowOf(PagingData.empty()) - } else { - playStorePagingRepository.playStoreSearch( - query = request.query, - pageSize = DEFAULT_PLAY_STORE_PAGE_SIZE - ).map { pagingData -> - pagingData.map { app -> - reconcile(app.toApplication(appContext)) - } - } - } - } - .flatMapLatest { it } - .cachedIn(viewModelScope) - private fun Flow>.withStatus(): Flow> = combine( this, diff --git a/app/src/test/java/foundation/e/apps/domain/search/CleanApkSearchPagingUseCaseTest.kt b/app/src/test/java/foundation/e/apps/domain/search/CleanApkSearchPagingUseCaseTest.kt new file mode 100644 index 000000000..a275a6014 --- /dev/null +++ b/app/src/test/java/foundation/e/apps/domain/search/CleanApkSearchPagingUseCaseTest.kt @@ -0,0 +1,141 @@ +/* + * 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.domain.search + +import androidx.paging.AsyncPagingDataDiffer +import androidx.paging.PagingData +import androidx.recyclerview.widget.ListUpdateCallback +import com.google.common.truth.Truth.assertThat +import foundation.e.apps.data.application.data.Application +import foundation.e.apps.data.search.CleanApkSearchParams +import foundation.e.apps.data.search.SearchPagingRepository +import foundation.e.apps.ui.applicationlist.ApplicationDiffUtil +import foundation.e.apps.ui.search.v2.SearchTabType +import foundation.e.apps.util.MainCoroutineRule +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class CleanApkSearchPagingUseCaseTest { + + @get:Rule + val mainCoroutineRule = MainCoroutineRule() + + private lateinit var searchPagingRepository: SearchPagingRepository + private lateinit var useCase: CleanApkSearchPagingUseCase + + @Before + fun setUp() { + searchPagingRepository = mockk(relaxed = true) + useCase = CleanApkSearchPagingUseCase(searchPagingRepository) + } + + @Test + fun `blank query emits empty paging data`() = runTest(mainCoroutineRule.testDispatcher) { + val requests = MutableStateFlow( + SearchRequest(query = "", visibleTabs = listOf(SearchTabType.OPEN_SOURCE), version = 1) + ) + + val pagingData = useCase( + requests = requests, + tab = SearchTabType.OPEN_SOURCE, + appSource = "cleanapk", + appType = "apps", + ).first() + + val items = collectApplications(pagingData) + + assertThat(items).isEmpty() + verify(exactly = 0) { searchPagingRepository.cleanApkSearch(any()) } + } + + @Test + fun `hidden tab emits empty paging data`() = runTest(mainCoroutineRule.testDispatcher) { + val requests = MutableStateFlow( + SearchRequest(query = "notes", visibleTabs = emptyList(), version = 1) + ) + + val pagingData = useCase( + requests = requests, + tab = SearchTabType.OPEN_SOURCE, + appSource = "cleanapk", + appType = "apps", + ).first() + + val items = collectApplications(pagingData) + + assertThat(items).isEmpty() + verify(exactly = 0) { searchPagingRepository.cleanApkSearch(any()) } + } + + @Test + fun `valid search builds cleanapk params`() = runTest(mainCoroutineRule.testDispatcher) { + val paramsSlot = slot() + every { searchPagingRepository.cleanApkSearch(capture(paramsSlot)) } returns flowOf( + PagingData.empty() + ) + val requests = MutableStateFlow( + SearchRequest(query = "notes", visibleTabs = listOf(SearchTabType.OPEN_SOURCE), version = 2) + ) + + useCase( + requests = requests, + tab = SearchTabType.OPEN_SOURCE, + appSource = "cleanapk", + appType = "apps", + ).first() + + assertThat(paramsSlot.captured.keyword).isEqualTo("notes") + assertThat(paramsSlot.captured.appSource).isEqualTo("cleanapk") + assertThat(paramsSlot.captured.appType).isEqualTo("apps") + } + + private suspend fun collectApplications(pagingData: PagingData): List { + val differ = AsyncPagingDataDiffer( + diffCallback = ApplicationDiffUtil(), + updateCallback = NoopListCallback(), + mainDispatcher = mainCoroutineRule.testDispatcher, + workerDispatcher = mainCoroutineRule.testDispatcher + ) + + differ.submitData(pagingData) + mainCoroutineRule.testDispatcher.scheduler.advanceUntilIdle() + return differ.snapshot().items + } + + private class NoopListCallback : ListUpdateCallback { + override fun onInserted(position: Int, count: Int) = Unit + + override fun onRemoved(position: Int, count: Int) = Unit + + override fun onMoved(fromPosition: Int, toPosition: Int) = Unit + + override fun onChanged(position: Int, count: Int, payload: Any?) = Unit + } +} diff --git a/app/src/test/java/foundation/e/apps/domain/search/PlayStoreSearchPagingUseCaseTest.kt b/app/src/test/java/foundation/e/apps/domain/search/PlayStoreSearchPagingUseCaseTest.kt new file mode 100644 index 000000000..89cb8aa7c --- /dev/null +++ b/app/src/test/java/foundation/e/apps/domain/search/PlayStoreSearchPagingUseCaseTest.kt @@ -0,0 +1,154 @@ +/* + * 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.domain.search + +import androidx.paging.AsyncPagingDataDiffer +import androidx.paging.PagingData +import androidx.recyclerview.widget.ListUpdateCallback +import com.aurora.gplayapi.data.models.App +import com.google.common.truth.Truth.assertThat +import foundation.e.apps.data.application.data.Application +import foundation.e.apps.data.search.PlayStorePagingRepository +import foundation.e.apps.ui.applicationlist.ApplicationDiffUtil +import foundation.e.apps.ui.search.v2.SearchTabType +import foundation.e.apps.util.MainCoroutineRule +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class PlayStoreSearchPagingUseCaseTest { + + @get:Rule + val mainCoroutineRule = MainCoroutineRule() + + private lateinit var playStorePagingRepository: PlayStorePagingRepository + private lateinit var playStoreAppMapper: PlayStoreAppMapper + private lateinit var useCase: PlayStoreSearchPagingUseCase + + @Before + fun setUp() { + playStorePagingRepository = mockk(relaxed = true) + playStoreAppMapper = mockk(relaxed = true) + useCase = PlayStoreSearchPagingUseCase(playStorePagingRepository, playStoreAppMapper) + } + + @Test + fun `blank query emits empty paging data`() = runTest(mainCoroutineRule.testDispatcher) { + val requests = MutableStateFlow( + SearchRequest(query = "", visibleTabs = listOf(SearchTabType.COMMON_APPS), version = 1) + ) + + val pagingData = useCase(requests, pageSize = 20).first() + val items = collectApplications(pagingData) + + assertThat(items).isEmpty() + verify(exactly = 0) { playStorePagingRepository.playStoreSearch(any(), any()) } + } + + @Test + fun `hidden tab emits empty paging data`() = runTest(mainCoroutineRule.testDispatcher) { + val requests = MutableStateFlow( + SearchRequest(query = "apps", visibleTabs = emptyList(), version = 1) + ) + + val pagingData = useCase(requests, pageSize = 20).first() + val items = collectApplications(pagingData) + + assertThat(items).isEmpty() + verify(exactly = 0) { playStorePagingRepository.playStoreSearch(any(), any()) } + } + + @Test + fun `valid search maps play store apps`() = runTest(mainCoroutineRule.testDispatcher) { + val app = samplePlayStoreApp("com.example.app") + val mapped = Application(_id = "com.example.app", package_name = "com.example.app") + every { playStorePagingRepository.playStoreSearch("apps", 20) } returns flowOf( + PagingData.from(listOf(app)) + ) + every { playStoreAppMapper.map(app) } returns mapped + val requests = MutableStateFlow( + SearchRequest(query = "apps", visibleTabs = listOf(SearchTabType.COMMON_APPS), version = 2) + ) + + val pagingData = useCase(requests, pageSize = 20).first() + val items = collectApplications(pagingData) + + assertThat(items).containsExactly(mapped) + verify { playStoreAppMapper.map(app) } + } + + @Test + fun `request version change restarts paging flow`() = runTest(mainCoroutineRule.testDispatcher) { + every { playStorePagingRepository.playStoreSearch(any(), any()) } returns flowOf( + PagingData.empty() + ) + val requests = MutableStateFlow( + SearchRequest(query = "apps", visibleTabs = listOf(SearchTabType.COMMON_APPS), version = 1) + ) + val flow = useCase(requests, pageSize = 20) + + flow.first() + requests.value = SearchRequest( + query = "apps", + visibleTabs = listOf(SearchTabType.COMMON_APPS), + version = 2, + ) + flow.first() + + verify(exactly = 2) { playStorePagingRepository.playStoreSearch("apps", 20) } + } + + private suspend fun collectApplications(pagingData: PagingData): List { + val differ = AsyncPagingDataDiffer( + diffCallback = ApplicationDiffUtil(), + updateCallback = NoopListCallback(), + mainDispatcher = mainCoroutineRule.testDispatcher, + workerDispatcher = mainCoroutineRule.testDispatcher + ) + + differ.submitData(pagingData) + mainCoroutineRule.testDispatcher.scheduler.advanceUntilIdle() + return differ.snapshot().items + } + + private fun samplePlayStoreApp(packageName: String): App { + val app = mockk(relaxed = true) + every { app.packageName } returns packageName + return app + } + + private class NoopListCallback : ListUpdateCallback { + override fun onInserted(position: Int, count: Int) = Unit + + override fun onRemoved(position: Int, count: Int) = Unit + + override fun onMoved(fromPosition: Int, toPosition: Int) = Unit + + override fun onChanged(position: Int, count: Int, payload: Any?) = Unit + } +} diff --git a/app/src/test/java/foundation/e/apps/ui/search/v2/SearchViewModelV2Test.kt b/app/src/test/java/foundation/e/apps/ui/search/v2/SearchViewModelV2Test.kt index 41ac57c9e..b1dad5447 100644 --- a/app/src/test/java/foundation/e/apps/ui/search/v2/SearchViewModelV2Test.kt +++ b/app/src/test/java/foundation/e/apps/ui/search/v2/SearchViewModelV2Test.kt @@ -18,7 +18,6 @@ package foundation.e.apps.ui.search.v2 -import android.content.Context import androidx.paging.AsyncPagingDataDiffer import androidx.paging.PagingData import androidx.recyclerview.widget.ListUpdateCallback @@ -36,6 +35,9 @@ import foundation.e.apps.data.search.CleanApkSearchParams import foundation.e.apps.data.search.FakeSuggestionSource import foundation.e.apps.data.search.PlayStorePagingRepository import foundation.e.apps.data.search.SearchPagingRepository +import foundation.e.apps.domain.search.CleanApkSearchPagingUseCase +import foundation.e.apps.domain.search.PlayStoreAppMapper +import foundation.e.apps.domain.search.PlayStoreSearchPagingUseCase import foundation.e.apps.ui.applicationlist.ApplicationDiffUtil import foundation.e.apps.ui.compose.state.InstallStatusReconciler import foundation.e.apps.ui.compose.state.InstallStatusStream @@ -70,10 +72,12 @@ class SearchViewModelV2Test { private lateinit var preference: AppLoungePreference private lateinit var searchPagingRepository: SearchPagingRepository private lateinit var playStorePagingRepository: PlayStorePagingRepository + private lateinit var playStoreAppMapper: PlayStoreAppMapper + private lateinit var cleanApkSearchPagingUseCase: CleanApkSearchPagingUseCase + private lateinit var playStoreSearchPagingUseCase: PlayStoreSearchPagingUseCase private lateinit var stores: Stores private lateinit var installStatusStream: InstallStatusStream private lateinit var installStatusReconciler: InstallStatusReconciler - private lateinit var appContext: Context private var playStoreSelected = true private var openSourceSelected = true private var pwaSelected = false @@ -85,9 +89,14 @@ class SearchViewModelV2Test { preference = mockk(relaxed = true) searchPagingRepository = mockk(relaxed = true) playStorePagingRepository = mockk(relaxed = true) + playStoreAppMapper = mockk(relaxed = true) + cleanApkSearchPagingUseCase = CleanApkSearchPagingUseCase(searchPagingRepository) + playStoreSearchPagingUseCase = PlayStoreSearchPagingUseCase( + playStorePagingRepository, + playStoreAppMapper, + ) installStatusStream = mockk(relaxed = true) installStatusReconciler = mockk(relaxed = true) - appContext = mockk(relaxed = true) every { preference.isPlayStoreSelected() } answers { playStoreSelected } every { preference.isOpenSourceSelected() } answers { openSourceSelected } @@ -108,6 +117,12 @@ class SearchViewModelV2Test { coEvery { installStatusReconciler.reconcile(any(), any(), any()) } answers { InstallStatusReconciler.Result(args[0] as Application) } + every { playStoreAppMapper.map(any()) } answers { + Application( + _id = (args[0] as App).packageName, + package_name = (args[0] as App).packageName, + ) + } buildViewModel() } @@ -601,12 +616,11 @@ class SearchViewModelV2Test { viewModel = SearchViewModelV2( suggestionSource, preference, - searchPagingRepository, - playStorePagingRepository, + cleanApkSearchPagingUseCase, + playStoreSearchPagingUseCase, stores, installStatusStream, installStatusReconciler, - appContext, ) runStoreUpdates() } -- GitLab From f727d45519ddc4434e7d0f7cc94c65723924fd9b Mon Sep 17 00:00:00 2001 From: Fahim Masud Choudhury Date: Wed, 28 Jan 2026 20:36:49 +0600 Subject: [PATCH 07/17] refactor: extract search state use cases from SearchViewModelV2 Move query/tab/suggestion decisions into domain use cases and a UI reducer to keep the ViewModel focused and improve testability. Add unit coverage for the new search state logic. --- .../search/FetchSearchSuggestionsUseCase.kt | 35 +++++ .../search/PrepareSearchSubmissionUseCase.kt | 58 +++++++ .../search/ResolveVisibleSearchTabsUseCase.kt | 41 +++++ .../apps/domain/search/SearchStateResults.kt | 38 +++++ .../UpdateSearchForStoreSelectionUseCase.kt | 62 ++++++++ .../apps/ui/search/v2/SearchUiStateReducer.kt | 43 ++++++ .../e/apps/ui/search/v2/SearchViewModelV2.kt | 142 ++++++------------ .../FetchSearchSuggestionsUseCaseTest.kt | 77 ++++++++++ .../PrepareSearchSubmissionUseCaseTest.kt | 96 ++++++++++++ .../ResolveVisibleSearchTabsUseCaseTest.kt | 84 +++++++++++ ...pdateSearchForStoreSelectionUseCaseTest.kt | 105 +++++++++++++ .../ui/search/v2/SearchViewModelV2Test.kt | 20 ++- 12 files changed, 702 insertions(+), 99 deletions(-) create mode 100644 app/src/main/java/foundation/e/apps/domain/search/FetchSearchSuggestionsUseCase.kt create mode 100644 app/src/main/java/foundation/e/apps/domain/search/PrepareSearchSubmissionUseCase.kt create mode 100644 app/src/main/java/foundation/e/apps/domain/search/ResolveVisibleSearchTabsUseCase.kt create mode 100644 app/src/main/java/foundation/e/apps/domain/search/SearchStateResults.kt create mode 100644 app/src/main/java/foundation/e/apps/domain/search/UpdateSearchForStoreSelectionUseCase.kt create mode 100644 app/src/main/java/foundation/e/apps/ui/search/v2/SearchUiStateReducer.kt create mode 100644 app/src/test/java/foundation/e/apps/domain/search/FetchSearchSuggestionsUseCaseTest.kt create mode 100644 app/src/test/java/foundation/e/apps/domain/search/PrepareSearchSubmissionUseCaseTest.kt create mode 100644 app/src/test/java/foundation/e/apps/domain/search/ResolveVisibleSearchTabsUseCaseTest.kt create mode 100644 app/src/test/java/foundation/e/apps/domain/search/UpdateSearchForStoreSelectionUseCaseTest.kt diff --git a/app/src/main/java/foundation/e/apps/domain/search/FetchSearchSuggestionsUseCase.kt b/app/src/main/java/foundation/e/apps/domain/search/FetchSearchSuggestionsUseCase.kt new file mode 100644 index 000000000..49ed7ff37 --- /dev/null +++ b/app/src/main/java/foundation/e/apps/domain/search/FetchSearchSuggestionsUseCase.kt @@ -0,0 +1,35 @@ +/* + * 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.domain.search + +import foundation.e.apps.data.preference.AppLoungePreference +import foundation.e.apps.data.search.SuggestionSource +import javax.inject.Inject + +class FetchSearchSuggestionsUseCase @Inject constructor( + private val suggestionSource: SuggestionSource, + private val appLoungePreference: AppLoungePreference, +) { + suspend operator fun invoke(query: String): List { + if (query.isBlank() || !appLoungePreference.isPlayStoreSelected()) { + return emptyList() + } + return suggestionSource.suggest(query) + } +} diff --git a/app/src/main/java/foundation/e/apps/domain/search/PrepareSearchSubmissionUseCase.kt b/app/src/main/java/foundation/e/apps/domain/search/PrepareSearchSubmissionUseCase.kt new file mode 100644 index 000000000..6ae237046 --- /dev/null +++ b/app/src/main/java/foundation/e/apps/domain/search/PrepareSearchSubmissionUseCase.kt @@ -0,0 +1,58 @@ +/* + * 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.domain.search + +import foundation.e.apps.ui.search.v2.SearchTabType +import javax.inject.Inject + +class PrepareSearchSubmissionUseCase @Inject constructor( + private val resolveVisibleSearchTabsUseCase: ResolveVisibleSearchTabsUseCase, +) { + operator fun invoke( + submittedQuery: String, + selectedTab: SearchTabType?, + currentVersion: Int, + ): SearchSubmissionResult { + val trimmedQuery = submittedQuery.trim() + val visibleTabs = resolveVisibleSearchTabsUseCase() + val resolvedSelectedTab = selectedTab?.takeIf { visibleTabs.contains(it) } + ?: visibleTabs.firstOrNull() + val shouldIncrementVersion = trimmedQuery.isNotEmpty() + val nextVersion = if (shouldIncrementVersion) currentVersion + 1 else currentVersion + val hasSubmittedSearch = trimmedQuery.isNotEmpty() && visibleTabs.isNotEmpty() + val searchRequest = if (hasSubmittedSearch) { + SearchRequest( + query = trimmedQuery, + visibleTabs = visibleTabs, + version = nextVersion, + ) + } else { + null + } + + return SearchSubmissionResult( + trimmedQuery = trimmedQuery, + visibleTabs = visibleTabs, + selectedTab = resolvedSelectedTab, + hasSubmittedSearch = hasSubmittedSearch, + nextVersion = nextVersion, + searchRequest = searchRequest, + ) + } +} diff --git a/app/src/main/java/foundation/e/apps/domain/search/ResolveVisibleSearchTabsUseCase.kt b/app/src/main/java/foundation/e/apps/domain/search/ResolveVisibleSearchTabsUseCase.kt new file mode 100644 index 000000000..bfa5dffb9 --- /dev/null +++ b/app/src/main/java/foundation/e/apps/domain/search/ResolveVisibleSearchTabsUseCase.kt @@ -0,0 +1,41 @@ +/* + * 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.domain.search + +import foundation.e.apps.data.Stores +import foundation.e.apps.data.enums.Source.OPEN_SOURCE +import foundation.e.apps.data.enums.Source.PLAY_STORE +import foundation.e.apps.data.enums.Source.PWA +import foundation.e.apps.ui.search.v2.SearchTabType +import javax.inject.Inject + +class ResolveVisibleSearchTabsUseCase @Inject constructor( + private val stores: Stores, +) { + operator fun invoke(): List { + return stores.getStores().mapNotNull { (key, _) -> + when (key) { + PLAY_STORE -> SearchTabType.COMMON_APPS + OPEN_SOURCE -> SearchTabType.OPEN_SOURCE + PWA -> SearchTabType.PWA + else -> null + } + } + } +} diff --git a/app/src/main/java/foundation/e/apps/domain/search/SearchStateResults.kt b/app/src/main/java/foundation/e/apps/domain/search/SearchStateResults.kt new file mode 100644 index 000000000..faaef98f8 --- /dev/null +++ b/app/src/main/java/foundation/e/apps/domain/search/SearchStateResults.kt @@ -0,0 +1,38 @@ +/* + * 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.domain.search + +import foundation.e.apps.ui.search.v2.SearchTabType + +data class SearchSubmissionResult( + val trimmedQuery: String, + val visibleTabs: List, + val selectedTab: SearchTabType?, + val hasSubmittedSearch: Boolean, + val nextVersion: Int, + val searchRequest: SearchRequest?, +) + +data class StoreSelectionUpdate( + val visibleTabs: List, + val selectedTab: SearchTabType?, + val hasSubmittedSearch: Boolean, + val suggestionsEnabled: Boolean, + val searchRequest: SearchRequest?, +) diff --git a/app/src/main/java/foundation/e/apps/domain/search/UpdateSearchForStoreSelectionUseCase.kt b/app/src/main/java/foundation/e/apps/domain/search/UpdateSearchForStoreSelectionUseCase.kt new file mode 100644 index 000000000..69c6d4ce4 --- /dev/null +++ b/app/src/main/java/foundation/e/apps/domain/search/UpdateSearchForStoreSelectionUseCase.kt @@ -0,0 +1,62 @@ +/* + * 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.domain.search + +import foundation.e.apps.data.preference.AppLoungePreference +import foundation.e.apps.ui.search.v2.SearchTabType +import javax.inject.Inject + +class UpdateSearchForStoreSelectionUseCase @Inject constructor( + private val resolveVisibleSearchTabsUseCase: ResolveVisibleSearchTabsUseCase, + private val appLoungePreference: AppLoungePreference, +) { + operator fun invoke( + currentQuery: String, + selectedTab: SearchTabType?, + hasSubmittedSearch: Boolean, + currentVersion: Int, + ): StoreSelectionUpdate { + val visibleTabs = resolveVisibleSearchTabsUseCase() + val resolvedSelectedTab = selectedTab?.takeIf { visibleTabs.contains(it) } + ?: visibleTabs.firstOrNull() + val updatedHasSubmittedSearch = hasSubmittedSearch && visibleTabs.isNotEmpty() + val shouldUpdateRequest = hasSubmittedSearch && currentQuery.isNotBlank() + val searchRequest = when { + shouldUpdateRequest && visibleTabs.isNotEmpty() -> SearchRequest( + query = currentQuery, + visibleTabs = visibleTabs, + version = currentVersion, + ) + !hasSubmittedSearch -> SearchRequest( + query = "", + visibleTabs = visibleTabs, + version = currentVersion, + ) + else -> null + } + + return StoreSelectionUpdate( + visibleTabs = visibleTabs, + selectedTab = resolvedSelectedTab, + hasSubmittedSearch = updatedHasSubmittedSearch, + suggestionsEnabled = appLoungePreference.isPlayStoreSelected(), + searchRequest = searchRequest, + ) + } +} diff --git a/app/src/main/java/foundation/e/apps/ui/search/v2/SearchUiStateReducer.kt b/app/src/main/java/foundation/e/apps/ui/search/v2/SearchUiStateReducer.kt new file mode 100644 index 000000000..26363f456 --- /dev/null +++ b/app/src/main/java/foundation/e/apps/ui/search/v2/SearchUiStateReducer.kt @@ -0,0 +1,43 @@ +/* + * 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.ui.search.v2 + +object SearchUiStateReducer { + fun reduceQueryCleared( + current: SearchUiState, + visibleTabs: List, + ): SearchUiState { + return if (current.hasSubmittedSearch && current.availableTabs.isNotEmpty()) { + current.copy( + query = "", + suggestions = emptyList(), + isSuggestionVisible = false, + ) + } else { + current.copy( + query = "", + suggestions = emptyList(), + isSuggestionVisible = false, + hasSubmittedSearch = false, + availableTabs = visibleTabs, + selectedTab = visibleTabs.firstOrNull(), + ) + } + } +} diff --git a/app/src/main/java/foundation/e/apps/ui/search/v2/SearchViewModelV2.kt b/app/src/main/java/foundation/e/apps/ui/search/v2/SearchViewModelV2.kt index bb965f6e0..0c6d85d8e 100644 --- a/app/src/main/java/foundation/e/apps/ui/search/v2/SearchViewModelV2.kt +++ b/app/src/main/java/foundation/e/apps/ui/search/v2/SearchViewModelV2.kt @@ -27,15 +27,15 @@ import dagger.hilt.android.lifecycle.HiltViewModel import foundation.e.apps.data.Stores import foundation.e.apps.data.application.data.Application import foundation.e.apps.data.cleanapk.CleanApkRetrofit -import foundation.e.apps.data.enums.Source.OPEN_SOURCE -import foundation.e.apps.data.enums.Source.PLAY_STORE -import foundation.e.apps.data.enums.Source.PWA import foundation.e.apps.data.enums.Status import foundation.e.apps.data.preference.AppLoungePreference -import foundation.e.apps.data.search.SuggestionSource import foundation.e.apps.domain.search.CleanApkSearchPagingUseCase +import foundation.e.apps.domain.search.FetchSearchSuggestionsUseCase import foundation.e.apps.domain.search.PlayStoreSearchPagingUseCase +import foundation.e.apps.domain.search.PrepareSearchSubmissionUseCase +import foundation.e.apps.domain.search.ResolveVisibleSearchTabsUseCase import foundation.e.apps.domain.search.SearchRequest +import foundation.e.apps.domain.search.UpdateSearchForStoreSelectionUseCase import foundation.e.apps.install.download.data.DownloadProgress import foundation.e.apps.ui.compose.state.InstallStatusReconciler import foundation.e.apps.ui.compose.state.InstallStatusStream @@ -48,7 +48,6 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject @@ -71,7 +70,7 @@ data class SearchUiState( val searchVersion: Int = 0, ) -/** +/* * Captures scroll restoration state for a given search tab. * index: first visible item index; offset: pixel offset within that item. */ @@ -83,16 +82,19 @@ data class ScrollPosition( @HiltViewModel @Suppress("LongParameterList") class SearchViewModelV2 @Inject constructor( - private val suggestionSource: SuggestionSource, private val appLoungePreference: AppLoungePreference, - private val cleanApkSearchPagingUseCase: CleanApkSearchPagingUseCase, - private val playStoreSearchPagingUseCase: PlayStoreSearchPagingUseCase, + cleanApkSearchPagingUseCase: CleanApkSearchPagingUseCase, + playStoreSearchPagingUseCase: PlayStoreSearchPagingUseCase, + private val resolveVisibleSearchTabsUseCase: ResolveVisibleSearchTabsUseCase, + private val fetchSearchSuggestionsUseCase: FetchSearchSuggestionsUseCase, + private val prepareSearchSubmissionUseCase: PrepareSearchSubmissionUseCase, + private val updateSearchForStoreSelectionUseCase: UpdateSearchForStoreSelectionUseCase, private val stores: Stores, private val installStatusStream: InstallStatusStream, private val installStatusReconciler: InstallStatusReconciler, ) : ViewModel() { - private val initialVisibleTabs = resolveVisibleTabs() + private val initialVisibleTabs = resolveVisibleSearchTabsUseCase() private val _uiState = MutableStateFlow( SearchUiState( @@ -148,24 +150,9 @@ class SearchViewModelV2 @Inject constructor( suggestionJob?.cancel() if (newQuery.isBlank()) { + val visibleTabs = resolveVisibleSearchTabsUseCase() _uiState.update { current -> - if (current.hasSubmittedSearch && current.availableTabs.isNotEmpty()) { - current.copy( - suggestions = emptyList(), - isSuggestionVisible = false, - query = "", - ) - } else { - val visibleTabs = resolveVisibleTabs() - current.copy( - suggestions = emptyList(), - isSuggestionVisible = false, - hasSubmittedSearch = false, - availableTabs = visibleTabs, - selectedTab = visibleTabs.firstOrNull(), - query = "", - ) - } + SearchUiStateReducer.reduceQueryCleared(current, visibleTabs) } return } @@ -182,7 +169,7 @@ class SearchViewModelV2 @Inject constructor( suggestionJob = viewModelScope.launch { delay(SUGGESTION_DEBOUNCE_MS) - val suggestions = suggestionSource.suggest(newQuery) + val suggestions = fetchSearchSuggestionsUseCase(newQuery) _uiState.update { current -> current.copy( suggestions = suggestions, @@ -198,59 +185,38 @@ class SearchViewModelV2 @Inject constructor( fun onQueryCleared() { suggestionJob?.cancel() + val visibleTabs = resolveVisibleSearchTabsUseCase() _uiState.update { current -> - if (current.hasSubmittedSearch && current.availableTabs.isNotEmpty()) { - current.copy( - query = "", - suggestions = emptyList(), - isSuggestionVisible = false, - ) - } else { - val visibleTabs = resolveVisibleTabs() - current.copy( - query = "", - suggestions = emptyList(), - isSuggestionVisible = false, - hasSubmittedSearch = false, - availableTabs = resolveVisibleTabs(), - selectedTab = resolveVisibleTabs().firstOrNull(), - ) - } + SearchUiStateReducer.reduceQueryCleared(current, visibleTabs) } } fun onSearchSubmitted(submitted: String) { - val trimmedQuery = submitted.trim() - if (trimmedQuery.isEmpty()) { + val currentState = _uiState.value + val result = prepareSearchSubmissionUseCase( + submittedQuery = submitted, + selectedTab = currentState.selectedTab, + currentVersion = currentState.searchVersion, + ) + if (result.trimmedQuery.isEmpty()) { onQueryCleared() return } - val visibleTabs = resolveVisibleTabs() - - val selectedTab = _uiState.value.selectedTab?.takeIf { visibleTabs.contains(it) } - ?: visibleTabs.firstOrNull() - - var nextVersion = _uiState.value.searchVersion + 1 _uiState.update { current -> - nextVersion = current.searchVersion + 1 current.copy( - query = trimmedQuery, + query = result.trimmedQuery, suggestions = emptyList(), isSuggestionVisible = false, - availableTabs = visibleTabs, - selectedTab = selectedTab, - hasSubmittedSearch = visibleTabs.isNotEmpty(), - searchVersion = nextVersion, + availableTabs = result.visibleTabs, + selectedTab = result.selectedTab, + hasSubmittedSearch = result.hasSubmittedSearch, + searchVersion = result.nextVersion, ) } - if (visibleTabs.isNotEmpty()) { - searchRequests.value = SearchRequest( - query = trimmedQuery, - visibleTabs = visibleTabs, - version = nextVersion, - ) + result.searchRequest?.let { request -> + searchRequests.value = request _scrollPositions.update { emptyMap() } } } @@ -266,35 +232,25 @@ class SearchViewModelV2 @Inject constructor( } private fun handleStoreSelectionChanged() { - val visibleTabs = resolveVisibleTabs() + val currentState = _uiState.value + val update = updateSearchForStoreSelectionUseCase( + currentQuery = currentState.query, + selectedTab = currentState.selectedTab, + hasSubmittedSearch = currentState.hasSubmittedSearch, + currentVersion = currentState.searchVersion, + ) _uiState.update { current -> - val selectedTab = current.selectedTab?.takeIf { visibleTabs.contains(it) } - ?: visibleTabs.firstOrNull() - current.copy( - availableTabs = visibleTabs, - selectedTab = selectedTab, - hasSubmittedSearch = current.hasSubmittedSearch && visibleTabs.isNotEmpty(), - isSuggestionVisible = current.isSuggestionVisible && appLoungePreference.isPlayStoreSelected(), + availableTabs = update.visibleTabs, + selectedTab = update.selectedTab, + hasSubmittedSearch = update.hasSubmittedSearch, + isSuggestionVisible = current.isSuggestionVisible && update.suggestionsEnabled, ) } - val currentState = _uiState.value - val currentQuery = currentState.query - val shouldUpdateRequest = currentState.hasSubmittedSearch && currentQuery.isNotBlank() - if (shouldUpdateRequest && visibleTabs.isNotEmpty()) { - searchRequests.value = SearchRequest( - query = currentQuery, - visibleTabs = visibleTabs, - version = currentState.searchVersion, - ) - } else if (!currentState.hasSubmittedSearch) { - searchRequests.value = SearchRequest( - query = "", - visibleTabs = visibleTabs, - version = currentState.searchVersion, - ) + update.searchRequest?.let { request -> + searchRequests.value = request } } @@ -319,16 +275,6 @@ class SearchViewModelV2 @Inject constructor( downloadProgress.value = progress } - private fun resolveVisibleTabs(): List = - stores.getStores().mapNotNull { (key, _) -> - when (key) { - PLAY_STORE -> SearchTabType.COMMON_APPS - OPEN_SOURCE -> SearchTabType.OPEN_SOURCE - PWA -> SearchTabType.PWA - else -> null - } - } - private fun Flow>.withStatus(): Flow> = combine( this, diff --git a/app/src/test/java/foundation/e/apps/domain/search/FetchSearchSuggestionsUseCaseTest.kt b/app/src/test/java/foundation/e/apps/domain/search/FetchSearchSuggestionsUseCaseTest.kt new file mode 100644 index 000000000..c5c2d1cea --- /dev/null +++ b/app/src/test/java/foundation/e/apps/domain/search/FetchSearchSuggestionsUseCaseTest.kt @@ -0,0 +1,77 @@ +/* + * 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.domain.search + +import com.google.common.truth.Truth.assertThat +import foundation.e.apps.data.preference.AppLoungePreference +import foundation.e.apps.data.search.SuggestionSource +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class FetchSearchSuggestionsUseCaseTest { + + private lateinit var suggestionSource: SuggestionSource + private lateinit var appLoungePreference: AppLoungePreference + private lateinit var useCase: FetchSearchSuggestionsUseCase + + @Before + fun setUp() { + suggestionSource = mockk() + appLoungePreference = mockk() + useCase = FetchSearchSuggestionsUseCase(suggestionSource, appLoungePreference) + } + + @Test + fun `blank query yields empty suggestions`() = runTest { + every { appLoungePreference.isPlayStoreSelected() } returns true + + val result = useCase(" ") + + assertThat(result).isEmpty() + coVerify(exactly = 0) { suggestionSource.suggest(any()) } + } + + @Test + fun `play store disabled yields empty suggestions`() = runTest { + every { appLoungePreference.isPlayStoreSelected() } returns false + + val result = useCase("notes") + + assertThat(result).isEmpty() + coVerify(exactly = 0) { suggestionSource.suggest(any()) } + } + + @Test + fun `eligible query returns suggestion results`() = runTest { + every { appLoungePreference.isPlayStoreSelected() } returns true + coEvery { suggestionSource.suggest("notes") } returns listOf("notes app") + + val result = useCase("notes") + + assertThat(result).containsExactly("notes app") + coVerify { suggestionSource.suggest("notes") } + } +} diff --git a/app/src/test/java/foundation/e/apps/domain/search/PrepareSearchSubmissionUseCaseTest.kt b/app/src/test/java/foundation/e/apps/domain/search/PrepareSearchSubmissionUseCaseTest.kt new file mode 100644 index 000000000..29ce57d82 --- /dev/null +++ b/app/src/test/java/foundation/e/apps/domain/search/PrepareSearchSubmissionUseCaseTest.kt @@ -0,0 +1,96 @@ +/* + * 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.domain.search + +import com.google.common.truth.Truth.assertThat +import foundation.e.apps.ui.search.v2.SearchTabType +import io.mockk.every +import io.mockk.mockk +import org.junit.Before +import org.junit.Test + +class PrepareSearchSubmissionUseCaseTest { + + private lateinit var resolveVisibleSearchTabsUseCase: ResolveVisibleSearchTabsUseCase + private lateinit var useCase: PrepareSearchSubmissionUseCase + + @Before + fun setUp() { + resolveVisibleSearchTabsUseCase = mockk() + useCase = PrepareSearchSubmissionUseCase(resolveVisibleSearchTabsUseCase) + } + + @Test + fun `blank submission does not increment version or create request`() { + every { resolveVisibleSearchTabsUseCase() } returns listOf(SearchTabType.COMMON_APPS) + + val result = useCase( + submittedQuery = " ", + selectedTab = SearchTabType.COMMON_APPS, + currentVersion = 3, + ) + + assertThat(result.trimmedQuery).isEmpty() + assertThat(result.nextVersion).isEqualTo(3) + assertThat(result.searchRequest).isNull() + assertThat(result.hasSubmittedSearch).isFalse() + assertThat(result.selectedTab).isEqualTo(SearchTabType.COMMON_APPS) + } + + @Test + fun `non-blank submission increments version without visible tabs`() { + every { resolveVisibleSearchTabsUseCase() } returns emptyList() + + val result = useCase( + submittedQuery = "apps", + selectedTab = SearchTabType.OPEN_SOURCE, + currentVersion = 2, + ) + + assertThat(result.nextVersion).isEqualTo(3) + assertThat(result.visibleTabs).isEmpty() + assertThat(result.searchRequest).isNull() + assertThat(result.hasSubmittedSearch).isFalse() + assertThat(result.selectedTab).isNull() + } + + @Test + fun `valid submission returns request with resolved tab`() { + every { resolveVisibleSearchTabsUseCase() } returns listOf( + SearchTabType.COMMON_APPS, + SearchTabType.OPEN_SOURCE, + ) + + val result = useCase( + submittedQuery = " notes ", + selectedTab = SearchTabType.PWA, + currentVersion = 1, + ) + + assertThat(result.nextVersion).isEqualTo(2) + assertThat(result.selectedTab).isEqualTo(SearchTabType.COMMON_APPS) + assertThat(result.hasSubmittedSearch).isTrue() + assertThat(result.searchRequest).isNotNull() + assertThat(result.searchRequest?.query).isEqualTo("notes") + assertThat(result.searchRequest?.visibleTabs).containsExactly( + SearchTabType.COMMON_APPS, + SearchTabType.OPEN_SOURCE, + ).inOrder() + } +} diff --git a/app/src/test/java/foundation/e/apps/domain/search/ResolveVisibleSearchTabsUseCaseTest.kt b/app/src/test/java/foundation/e/apps/domain/search/ResolveVisibleSearchTabsUseCaseTest.kt new file mode 100644 index 000000000..bd13d6d8f --- /dev/null +++ b/app/src/test/java/foundation/e/apps/domain/search/ResolveVisibleSearchTabsUseCaseTest.kt @@ -0,0 +1,84 @@ +/* + * 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.domain.search + +import com.google.common.truth.Truth.assertThat +import foundation.e.apps.data.StoreRepository +import foundation.e.apps.data.Stores +import foundation.e.apps.data.enums.Source.OPEN_SOURCE +import foundation.e.apps.data.enums.Source.PLAY_STORE +import foundation.e.apps.data.enums.Source.PWA +import foundation.e.apps.data.enums.Source.SYSTEM_APP +import foundation.e.apps.ui.search.v2.SearchTabType +import io.mockk.every +import io.mockk.mockk +import org.junit.Before +import org.junit.Test + +class ResolveVisibleSearchTabsUseCaseTest { + + private lateinit var stores: Stores + private lateinit var useCase: ResolveVisibleSearchTabsUseCase + + @Before + fun setUp() { + stores = mockk() + useCase = ResolveVisibleSearchTabsUseCase(stores) + } + + @Test + fun `maps enabled stores to tabs in order`() { + val storeRepository = mockk() + every { stores.getStores() } returns linkedMapOf( + PLAY_STORE to storeRepository, + OPEN_SOURCE to storeRepository, + PWA to storeRepository, + ) + + val result = useCase() + + assertThat(result).containsExactly( + SearchTabType.COMMON_APPS, + SearchTabType.OPEN_SOURCE, + SearchTabType.PWA, + ).inOrder() + } + + @Test + fun `ignores unsupported store sources`() { + val storeRepository = mockk() + every { stores.getStores() } returns linkedMapOf( + SYSTEM_APP to storeRepository, + OPEN_SOURCE to storeRepository, + ) + + val result = useCase() + + assertThat(result).containsExactly(SearchTabType.OPEN_SOURCE) + } + + @Test + fun `returns empty list when no stores are enabled`() { + every { stores.getStores() } returns emptyMap() + + val result = useCase() + + assertThat(result).isEmpty() + } +} diff --git a/app/src/test/java/foundation/e/apps/domain/search/UpdateSearchForStoreSelectionUseCaseTest.kt b/app/src/test/java/foundation/e/apps/domain/search/UpdateSearchForStoreSelectionUseCaseTest.kt new file mode 100644 index 000000000..1508eb0ec --- /dev/null +++ b/app/src/test/java/foundation/e/apps/domain/search/UpdateSearchForStoreSelectionUseCaseTest.kt @@ -0,0 +1,105 @@ +/* + * 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.domain.search + +import com.google.common.truth.Truth.assertThat +import foundation.e.apps.data.preference.AppLoungePreference +import foundation.e.apps.ui.search.v2.SearchTabType +import io.mockk.every +import io.mockk.mockk +import org.junit.Before +import org.junit.Test + +class UpdateSearchForStoreSelectionUseCaseTest { + + private lateinit var resolveVisibleSearchTabsUseCase: ResolveVisibleSearchTabsUseCase + private lateinit var appLoungePreference: AppLoungePreference + private lateinit var useCase: UpdateSearchForStoreSelectionUseCase + + @Before + fun setUp() { + resolveVisibleSearchTabsUseCase = mockk() + appLoungePreference = mockk() + useCase = UpdateSearchForStoreSelectionUseCase( + resolveVisibleSearchTabsUseCase, + appLoungePreference, + ) + } + + @Test + fun `submitted search updates request when visible tabs are available`() { + every { resolveVisibleSearchTabsUseCase() } returns listOf( + SearchTabType.COMMON_APPS, + SearchTabType.PWA, + ) + every { appLoungePreference.isPlayStoreSelected() } returns true + + val result = useCase( + currentQuery = "apps", + selectedTab = SearchTabType.PWA, + hasSubmittedSearch = true, + currentVersion = 4, + ) + + assertThat(result.searchRequest).isNotNull() + assertThat(result.searchRequest?.query).isEqualTo("apps") + assertThat(result.searchRequest?.version).isEqualTo(4) + assertThat(result.selectedTab).isEqualTo(SearchTabType.PWA) + assertThat(result.hasSubmittedSearch).isTrue() + assertThat(result.suggestionsEnabled).isTrue() + } + + @Test + fun `no submitted search emits empty query request`() { + every { resolveVisibleSearchTabsUseCase() } returns listOf(SearchTabType.OPEN_SOURCE) + every { appLoungePreference.isPlayStoreSelected() } returns false + + val result = useCase( + currentQuery = "", + selectedTab = SearchTabType.OPEN_SOURCE, + hasSubmittedSearch = false, + currentVersion = 2, + ) + + assertThat(result.searchRequest).isNotNull() + assertThat(result.searchRequest?.query).isEqualTo("") + assertThat(result.searchRequest?.version).isEqualTo(2) + assertThat(result.hasSubmittedSearch).isFalse() + assertThat(result.suggestionsEnabled).isFalse() + assertThat(result.selectedTab).isEqualTo(SearchTabType.OPEN_SOURCE) + } + + @Test + fun `empty visible tabs clear selection and skip request update`() { + every { resolveVisibleSearchTabsUseCase() } returns emptyList() + every { appLoungePreference.isPlayStoreSelected() } returns true + + val result = useCase( + currentQuery = "apps", + selectedTab = SearchTabType.COMMON_APPS, + hasSubmittedSearch = true, + currentVersion = 1, + ) + + assertThat(result.visibleTabs).isEmpty() + assertThat(result.selectedTab).isNull() + assertThat(result.hasSubmittedSearch).isFalse() + assertThat(result.searchRequest).isNull() + } +} diff --git a/app/src/test/java/foundation/e/apps/ui/search/v2/SearchViewModelV2Test.kt b/app/src/test/java/foundation/e/apps/ui/search/v2/SearchViewModelV2Test.kt index b1dad5447..aefa3a66f 100644 --- a/app/src/test/java/foundation/e/apps/ui/search/v2/SearchViewModelV2Test.kt +++ b/app/src/test/java/foundation/e/apps/ui/search/v2/SearchViewModelV2Test.kt @@ -36,8 +36,12 @@ import foundation.e.apps.data.search.FakeSuggestionSource import foundation.e.apps.data.search.PlayStorePagingRepository import foundation.e.apps.data.search.SearchPagingRepository import foundation.e.apps.domain.search.CleanApkSearchPagingUseCase +import foundation.e.apps.domain.search.FetchSearchSuggestionsUseCase import foundation.e.apps.domain.search.PlayStoreAppMapper import foundation.e.apps.domain.search.PlayStoreSearchPagingUseCase +import foundation.e.apps.domain.search.PrepareSearchSubmissionUseCase +import foundation.e.apps.domain.search.ResolveVisibleSearchTabsUseCase +import foundation.e.apps.domain.search.UpdateSearchForStoreSelectionUseCase import foundation.e.apps.ui.applicationlist.ApplicationDiffUtil import foundation.e.apps.ui.compose.state.InstallStatusReconciler import foundation.e.apps.ui.compose.state.InstallStatusStream @@ -75,6 +79,10 @@ class SearchViewModelV2Test { private lateinit var playStoreAppMapper: PlayStoreAppMapper private lateinit var cleanApkSearchPagingUseCase: CleanApkSearchPagingUseCase private lateinit var playStoreSearchPagingUseCase: PlayStoreSearchPagingUseCase + private lateinit var resolveVisibleSearchTabsUseCase: ResolveVisibleSearchTabsUseCase + private lateinit var fetchSearchSuggestionsUseCase: FetchSearchSuggestionsUseCase + private lateinit var prepareSearchSubmissionUseCase: PrepareSearchSubmissionUseCase + private lateinit var updateSearchForStoreSelectionUseCase: UpdateSearchForStoreSelectionUseCase private lateinit var stores: Stores private lateinit var installStatusStream: InstallStatusStream private lateinit var installStatusReconciler: InstallStatusReconciler @@ -613,11 +621,21 @@ class SearchViewModelV2Test { private fun buildViewModel() { stores = buildStores() + resolveVisibleSearchTabsUseCase = ResolveVisibleSearchTabsUseCase(stores) + fetchSearchSuggestionsUseCase = FetchSearchSuggestionsUseCase(suggestionSource, preference) + prepareSearchSubmissionUseCase = PrepareSearchSubmissionUseCase(resolveVisibleSearchTabsUseCase) + updateSearchForStoreSelectionUseCase = UpdateSearchForStoreSelectionUseCase( + resolveVisibleSearchTabsUseCase, + preference, + ) viewModel = SearchViewModelV2( - suggestionSource, preference, cleanApkSearchPagingUseCase, playStoreSearchPagingUseCase, + resolveVisibleSearchTabsUseCase, + fetchSearchSuggestionsUseCase, + prepareSearchSubmissionUseCase, + updateSearchForStoreSelectionUseCase, stores, installStatusStream, installStatusReconciler, -- GitLab From 6e8d0806d20482e2b33dfbd81311b97e99f981b1 Mon Sep 17 00:00:00 2001 From: Fahim Masud Choudhury Date: Thu, 29 Jan 2026 19:10:41 +0600 Subject: [PATCH 08/17] refactor: use Source instead of SearchTabType in domain layer to decide store availability --- .../search/CleanApkSearchPagingUseCase.kt | 6 +-- .../search/PlayStoreSearchPagingUseCase.kt | 5 +- .../search/PrepareSearchSubmissionUseCase.kt | 20 ++++---- ... => ResolveEnabledSearchSourcesUseCase.kt} | 10 ++-- .../e/apps/domain/search/SearchRequest.kt | 4 +- .../apps/domain/search/SearchStateResults.kt | 10 ++-- .../UpdateSearchForStoreSelectionUseCase.kt | 24 ++++----- .../e/apps/ui/search/v2/SearchViewModelV2.kt | 49 +++++++++++++------ .../search/CleanApkSearchPagingUseCaseTest.kt | 14 +++--- .../PlayStoreSearchPagingUseCaseTest.kt | 12 ++--- .../PrepareSearchSubmissionUseCaseTest.kt | 42 ++++++++-------- ...ResolveEnabledSearchSourcesUseCaseTest.kt} | 18 +++---- ...pdateSearchForStoreSelectionUseCaseTest.kt | 36 +++++++------- .../ui/search/v2/SearchViewModelV2Test.kt | 12 ++--- 14 files changed, 140 insertions(+), 122 deletions(-) rename app/src/main/java/foundation/e/apps/domain/search/{ResolveVisibleSearchTabsUseCase.kt => ResolveEnabledSearchSourcesUseCase.kt} (78%) rename app/src/test/java/foundation/e/apps/domain/search/{ResolveVisibleSearchTabsUseCaseTest.kt => ResolveEnabledSearchSourcesUseCaseTest.kt} (82%) diff --git a/app/src/main/java/foundation/e/apps/domain/search/CleanApkSearchPagingUseCase.kt b/app/src/main/java/foundation/e/apps/domain/search/CleanApkSearchPagingUseCase.kt index 5331b7750..d65939f22 100644 --- a/app/src/main/java/foundation/e/apps/domain/search/CleanApkSearchPagingUseCase.kt +++ b/app/src/main/java/foundation/e/apps/domain/search/CleanApkSearchPagingUseCase.kt @@ -20,9 +20,9 @@ package foundation.e.apps.domain.search import androidx.paging.PagingData import foundation.e.apps.data.application.data.Application +import foundation.e.apps.data.enums.Source import foundation.e.apps.data.search.CleanApkSearchParams import foundation.e.apps.data.search.SearchPagingRepository -import foundation.e.apps.ui.search.v2.SearchTabType import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.filterNotNull @@ -37,14 +37,14 @@ class CleanApkSearchPagingUseCase @Inject constructor( @OptIn(ExperimentalCoroutinesApi::class) operator fun invoke( requests: Flow, - tab: SearchTabType, + source: Source, appSource: String, appType: String, ): Flow> { return requests .filterNotNull() .mapLatest { request -> - if (!request.visibleTabs.contains(tab) || request.query.isBlank()) { + if (!request.enabledSources.contains(source) || request.query.isBlank()) { flowOf(PagingData.empty()) } else { searchPagingRepository.cleanApkSearch( diff --git a/app/src/main/java/foundation/e/apps/domain/search/PlayStoreSearchPagingUseCase.kt b/app/src/main/java/foundation/e/apps/domain/search/PlayStoreSearchPagingUseCase.kt index 67baab98f..0ab2150f1 100644 --- a/app/src/main/java/foundation/e/apps/domain/search/PlayStoreSearchPagingUseCase.kt +++ b/app/src/main/java/foundation/e/apps/domain/search/PlayStoreSearchPagingUseCase.kt @@ -22,8 +22,8 @@ import androidx.paging.PagingData import androidx.paging.map import com.aurora.gplayapi.data.models.App import foundation.e.apps.data.application.data.Application +import foundation.e.apps.data.enums.Source import foundation.e.apps.data.search.PlayStorePagingRepository -import foundation.e.apps.ui.search.v2.SearchTabType import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.filterNotNull @@ -41,12 +41,11 @@ class PlayStoreSearchPagingUseCase @Inject constructor( operator fun invoke( requests: Flow, pageSize: Int, - tab: SearchTabType = SearchTabType.COMMON_APPS, ): Flow> { return requests .filterNotNull() .mapLatest { request -> - if (!request.visibleTabs.contains(tab) || request.query.isBlank()) { + if (!request.enabledSources.contains(Source.PLAY_STORE) || request.query.isBlank()) { flowOf(PagingData.empty()) } else { playStorePagingRepository.playStoreSearch( diff --git a/app/src/main/java/foundation/e/apps/domain/search/PrepareSearchSubmissionUseCase.kt b/app/src/main/java/foundation/e/apps/domain/search/PrepareSearchSubmissionUseCase.kt index 6ae237046..b01787f74 100644 --- a/app/src/main/java/foundation/e/apps/domain/search/PrepareSearchSubmissionUseCase.kt +++ b/app/src/main/java/foundation/e/apps/domain/search/PrepareSearchSubmissionUseCase.kt @@ -18,28 +18,28 @@ package foundation.e.apps.domain.search -import foundation.e.apps.ui.search.v2.SearchTabType +import foundation.e.apps.data.enums.Source import javax.inject.Inject class PrepareSearchSubmissionUseCase @Inject constructor( - private val resolveVisibleSearchTabsUseCase: ResolveVisibleSearchTabsUseCase, + private val resolveEnabledSearchSourcesUseCase: ResolveEnabledSearchSourcesUseCase, ) { operator fun invoke( submittedQuery: String, - selectedTab: SearchTabType?, + selectedSource: Source?, currentVersion: Int, ): SearchSubmissionResult { val trimmedQuery = submittedQuery.trim() - val visibleTabs = resolveVisibleSearchTabsUseCase() - val resolvedSelectedTab = selectedTab?.takeIf { visibleTabs.contains(it) } - ?: visibleTabs.firstOrNull() + val enabledSources = resolveEnabledSearchSourcesUseCase() + val resolvedSelectedSource = selectedSource?.takeIf { enabledSources.contains(it) } + ?: enabledSources.firstOrNull() val shouldIncrementVersion = trimmedQuery.isNotEmpty() val nextVersion = if (shouldIncrementVersion) currentVersion + 1 else currentVersion - val hasSubmittedSearch = trimmedQuery.isNotEmpty() && visibleTabs.isNotEmpty() + val hasSubmittedSearch = trimmedQuery.isNotEmpty() && enabledSources.isNotEmpty() val searchRequest = if (hasSubmittedSearch) { SearchRequest( query = trimmedQuery, - visibleTabs = visibleTabs, + enabledSources = enabledSources, version = nextVersion, ) } else { @@ -48,8 +48,8 @@ class PrepareSearchSubmissionUseCase @Inject constructor( return SearchSubmissionResult( trimmedQuery = trimmedQuery, - visibleTabs = visibleTabs, - selectedTab = resolvedSelectedTab, + enabledSources = enabledSources, + selectedSource = resolvedSelectedSource, hasSubmittedSearch = hasSubmittedSearch, nextVersion = nextVersion, searchRequest = searchRequest, diff --git a/app/src/main/java/foundation/e/apps/domain/search/ResolveVisibleSearchTabsUseCase.kt b/app/src/main/java/foundation/e/apps/domain/search/ResolveEnabledSearchSourcesUseCase.kt similarity index 78% rename from app/src/main/java/foundation/e/apps/domain/search/ResolveVisibleSearchTabsUseCase.kt rename to app/src/main/java/foundation/e/apps/domain/search/ResolveEnabledSearchSourcesUseCase.kt index bfa5dffb9..eda74b883 100644 --- a/app/src/main/java/foundation/e/apps/domain/search/ResolveVisibleSearchTabsUseCase.kt +++ b/app/src/main/java/foundation/e/apps/domain/search/ResolveEnabledSearchSourcesUseCase.kt @@ -19,21 +19,19 @@ package foundation.e.apps.domain.search import foundation.e.apps.data.Stores +import foundation.e.apps.data.enums.Source import foundation.e.apps.data.enums.Source.OPEN_SOURCE import foundation.e.apps.data.enums.Source.PLAY_STORE import foundation.e.apps.data.enums.Source.PWA -import foundation.e.apps.ui.search.v2.SearchTabType import javax.inject.Inject -class ResolveVisibleSearchTabsUseCase @Inject constructor( +class ResolveEnabledSearchSourcesUseCase @Inject constructor( private val stores: Stores, ) { - operator fun invoke(): List { + operator fun invoke(): List { return stores.getStores().mapNotNull { (key, _) -> when (key) { - PLAY_STORE -> SearchTabType.COMMON_APPS - OPEN_SOURCE -> SearchTabType.OPEN_SOURCE - PWA -> SearchTabType.PWA + PLAY_STORE, OPEN_SOURCE, PWA -> key else -> null } } diff --git a/app/src/main/java/foundation/e/apps/domain/search/SearchRequest.kt b/app/src/main/java/foundation/e/apps/domain/search/SearchRequest.kt index fe3a91cda..0615e84e0 100644 --- a/app/src/main/java/foundation/e/apps/domain/search/SearchRequest.kt +++ b/app/src/main/java/foundation/e/apps/domain/search/SearchRequest.kt @@ -18,10 +18,10 @@ package foundation.e.apps.domain.search -import foundation.e.apps.ui.search.v2.SearchTabType +import foundation.e.apps.data.enums.Source data class SearchRequest( val query: String, - val visibleTabs: List, + val enabledSources: List, val version: Int, ) diff --git a/app/src/main/java/foundation/e/apps/domain/search/SearchStateResults.kt b/app/src/main/java/foundation/e/apps/domain/search/SearchStateResults.kt index faaef98f8..ea7f0fddc 100644 --- a/app/src/main/java/foundation/e/apps/domain/search/SearchStateResults.kt +++ b/app/src/main/java/foundation/e/apps/domain/search/SearchStateResults.kt @@ -18,20 +18,20 @@ package foundation.e.apps.domain.search -import foundation.e.apps.ui.search.v2.SearchTabType +import foundation.e.apps.data.enums.Source data class SearchSubmissionResult( val trimmedQuery: String, - val visibleTabs: List, - val selectedTab: SearchTabType?, + val enabledSources: List, + val selectedSource: Source?, val hasSubmittedSearch: Boolean, val nextVersion: Int, val searchRequest: SearchRequest?, ) data class StoreSelectionUpdate( - val visibleTabs: List, - val selectedTab: SearchTabType?, + val enabledSources: List, + val selectedSource: Source?, val hasSubmittedSearch: Boolean, val suggestionsEnabled: Boolean, val searchRequest: SearchRequest?, diff --git a/app/src/main/java/foundation/e/apps/domain/search/UpdateSearchForStoreSelectionUseCase.kt b/app/src/main/java/foundation/e/apps/domain/search/UpdateSearchForStoreSelectionUseCase.kt index 69c6d4ce4..cc2cb8bf6 100644 --- a/app/src/main/java/foundation/e/apps/domain/search/UpdateSearchForStoreSelectionUseCase.kt +++ b/app/src/main/java/foundation/e/apps/domain/search/UpdateSearchForStoreSelectionUseCase.kt @@ -18,42 +18,42 @@ package foundation.e.apps.domain.search +import foundation.e.apps.data.enums.Source import foundation.e.apps.data.preference.AppLoungePreference -import foundation.e.apps.ui.search.v2.SearchTabType import javax.inject.Inject class UpdateSearchForStoreSelectionUseCase @Inject constructor( - private val resolveVisibleSearchTabsUseCase: ResolveVisibleSearchTabsUseCase, + private val resolveEnabledSearchSourcesUseCase: ResolveEnabledSearchSourcesUseCase, private val appLoungePreference: AppLoungePreference, ) { operator fun invoke( currentQuery: String, - selectedTab: SearchTabType?, + selectedSource: Source?, hasSubmittedSearch: Boolean, currentVersion: Int, ): StoreSelectionUpdate { - val visibleTabs = resolveVisibleSearchTabsUseCase() - val resolvedSelectedTab = selectedTab?.takeIf { visibleTabs.contains(it) } - ?: visibleTabs.firstOrNull() - val updatedHasSubmittedSearch = hasSubmittedSearch && visibleTabs.isNotEmpty() + val enabledSources = resolveEnabledSearchSourcesUseCase() + val resolvedSelectedSource = selectedSource?.takeIf { enabledSources.contains(it) } + ?: enabledSources.firstOrNull() + val updatedHasSubmittedSearch = hasSubmittedSearch && enabledSources.isNotEmpty() val shouldUpdateRequest = hasSubmittedSearch && currentQuery.isNotBlank() val searchRequest = when { - shouldUpdateRequest && visibleTabs.isNotEmpty() -> SearchRequest( + shouldUpdateRequest && enabledSources.isNotEmpty() -> SearchRequest( query = currentQuery, - visibleTabs = visibleTabs, + enabledSources = enabledSources, version = currentVersion, ) !hasSubmittedSearch -> SearchRequest( query = "", - visibleTabs = visibleTabs, + enabledSources = enabledSources, version = currentVersion, ) else -> null } return StoreSelectionUpdate( - visibleTabs = visibleTabs, - selectedTab = resolvedSelectedTab, + enabledSources = enabledSources, + selectedSource = resolvedSelectedSource, hasSubmittedSearch = updatedHasSubmittedSearch, suggestionsEnabled = appLoungePreference.isPlayStoreSelected(), searchRequest = searchRequest, diff --git a/app/src/main/java/foundation/e/apps/ui/search/v2/SearchViewModelV2.kt b/app/src/main/java/foundation/e/apps/ui/search/v2/SearchViewModelV2.kt index 0c6d85d8e..ce4c277cf 100644 --- a/app/src/main/java/foundation/e/apps/ui/search/v2/SearchViewModelV2.kt +++ b/app/src/main/java/foundation/e/apps/ui/search/v2/SearchViewModelV2.kt @@ -27,13 +27,14 @@ import dagger.hilt.android.lifecycle.HiltViewModel import foundation.e.apps.data.Stores import foundation.e.apps.data.application.data.Application import foundation.e.apps.data.cleanapk.CleanApkRetrofit +import foundation.e.apps.data.enums.Source import foundation.e.apps.data.enums.Status import foundation.e.apps.data.preference.AppLoungePreference import foundation.e.apps.domain.search.CleanApkSearchPagingUseCase import foundation.e.apps.domain.search.FetchSearchSuggestionsUseCase import foundation.e.apps.domain.search.PlayStoreSearchPagingUseCase import foundation.e.apps.domain.search.PrepareSearchSubmissionUseCase -import foundation.e.apps.domain.search.ResolveVisibleSearchTabsUseCase +import foundation.e.apps.domain.search.ResolveEnabledSearchSourcesUseCase import foundation.e.apps.domain.search.SearchRequest import foundation.e.apps.domain.search.UpdateSearchForStoreSelectionUseCase import foundation.e.apps.install.download.data.DownloadProgress @@ -85,7 +86,7 @@ class SearchViewModelV2 @Inject constructor( private val appLoungePreference: AppLoungePreference, cleanApkSearchPagingUseCase: CleanApkSearchPagingUseCase, playStoreSearchPagingUseCase: PlayStoreSearchPagingUseCase, - private val resolveVisibleSearchTabsUseCase: ResolveVisibleSearchTabsUseCase, + private val resolveEnabledSearchSourcesUseCase: ResolveEnabledSearchSourcesUseCase, private val fetchSearchSuggestionsUseCase: FetchSearchSuggestionsUseCase, private val prepareSearchSubmissionUseCase: PrepareSearchSubmissionUseCase, private val updateSearchForStoreSelectionUseCase: UpdateSearchForStoreSelectionUseCase, @@ -94,7 +95,7 @@ class SearchViewModelV2 @Inject constructor( private val installStatusReconciler: InstallStatusReconciler, ) : ViewModel() { - private val initialVisibleTabs = resolveVisibleSearchTabsUseCase() + private val initialVisibleTabs = resolveEnabledSearchSourcesUseCase().toSearchTabTypes() private val _uiState = MutableStateFlow( SearchUiState( @@ -115,14 +116,14 @@ class SearchViewModelV2 @Inject constructor( val fossPagingFlow = cleanApkSearchPagingUseCase( requests = searchRequests, - tab = SearchTabType.OPEN_SOURCE, + source = Source.OPEN_SOURCE, appSource = CleanApkRetrofit.APP_SOURCE_FOSS, appType = CleanApkRetrofit.APP_TYPE_NATIVE, ).cachedIn(viewModelScope).withStatus() val pwaPagingFlow = cleanApkSearchPagingUseCase( requests = searchRequests, - tab = SearchTabType.PWA, + source = Source.PWA, appSource = CleanApkRetrofit.APP_SOURCE_ANY, appType = CleanApkRetrofit.APP_TYPE_PWA, ).cachedIn(viewModelScope).withStatus() @@ -130,7 +131,6 @@ class SearchViewModelV2 @Inject constructor( val playStorePagingFlow = playStoreSearchPagingUseCase( requests = searchRequests, pageSize = DEFAULT_PLAY_STORE_PAGE_SIZE, - tab = SearchTabType.COMMON_APPS, ).cachedIn(viewModelScope).withStatus() private var suggestionJob: Job? = null @@ -150,7 +150,7 @@ class SearchViewModelV2 @Inject constructor( suggestionJob?.cancel() if (newQuery.isBlank()) { - val visibleTabs = resolveVisibleSearchTabsUseCase() + val visibleTabs = resolveEnabledSearchSourcesUseCase().toSearchTabTypes() _uiState.update { current -> SearchUiStateReducer.reduceQueryCleared(current, visibleTabs) } @@ -185,7 +185,7 @@ class SearchViewModelV2 @Inject constructor( fun onQueryCleared() { suggestionJob?.cancel() - val visibleTabs = resolveVisibleSearchTabsUseCase() + val visibleTabs = resolveEnabledSearchSourcesUseCase().toSearchTabTypes() _uiState.update { current -> SearchUiStateReducer.reduceQueryCleared(current, visibleTabs) } @@ -195,7 +195,7 @@ class SearchViewModelV2 @Inject constructor( val currentState = _uiState.value val result = prepareSearchSubmissionUseCase( submittedQuery = submitted, - selectedTab = currentState.selectedTab, + selectedSource = currentState.selectedTab?.toSource(), currentVersion = currentState.searchVersion, ) if (result.trimmedQuery.isEmpty()) { @@ -208,8 +208,8 @@ class SearchViewModelV2 @Inject constructor( query = result.trimmedQuery, suggestions = emptyList(), isSuggestionVisible = false, - availableTabs = result.visibleTabs, - selectedTab = result.selectedTab, + availableTabs = result.enabledSources.toSearchTabTypes(), + selectedTab = result.selectedSource?.toSearchTabTypeOrNull(), hasSubmittedSearch = result.hasSubmittedSearch, searchVersion = result.nextVersion, ) @@ -235,15 +235,15 @@ class SearchViewModelV2 @Inject constructor( val currentState = _uiState.value val update = updateSearchForStoreSelectionUseCase( currentQuery = currentState.query, - selectedTab = currentState.selectedTab, + selectedSource = currentState.selectedTab?.toSource(), hasSubmittedSearch = currentState.hasSubmittedSearch, currentVersion = currentState.searchVersion, ) _uiState.update { current -> current.copy( - availableTabs = update.visibleTabs, - selectedTab = update.selectedTab, + availableTabs = update.enabledSources.toSearchTabTypes(), + selectedTab = update.selectedSource?.toSearchTabTypeOrNull(), hasSubmittedSearch = update.hasSubmittedSearch, isSuggestionVisible = current.isSuggestionVisible && update.suggestionsEnabled, ) @@ -332,3 +332,24 @@ class SearchViewModelV2 @Inject constructor( private const val DEFAULT_PLAY_STORE_PAGE_SIZE = 20 } } + +internal fun List.toSearchTabTypes(): List = mapNotNull { source -> + source.toSearchTabTypeOrNull() +} + +internal fun Source.toSearchTabTypeOrNull(): SearchTabType? { + return when (this) { + Source.PLAY_STORE -> SearchTabType.COMMON_APPS + Source.OPEN_SOURCE -> SearchTabType.OPEN_SOURCE + Source.PWA -> SearchTabType.PWA + else -> null + } +} + +internal fun SearchTabType.toSource(): Source { + return when (this) { + SearchTabType.COMMON_APPS -> Source.PLAY_STORE + SearchTabType.OPEN_SOURCE -> Source.OPEN_SOURCE + SearchTabType.PWA -> Source.PWA + } +} diff --git a/app/src/test/java/foundation/e/apps/domain/search/CleanApkSearchPagingUseCaseTest.kt b/app/src/test/java/foundation/e/apps/domain/search/CleanApkSearchPagingUseCaseTest.kt index a275a6014..7cdfcf23d 100644 --- a/app/src/test/java/foundation/e/apps/domain/search/CleanApkSearchPagingUseCaseTest.kt +++ b/app/src/test/java/foundation/e/apps/domain/search/CleanApkSearchPagingUseCaseTest.kt @@ -23,10 +23,10 @@ import androidx.paging.PagingData import androidx.recyclerview.widget.ListUpdateCallback import com.google.common.truth.Truth.assertThat import foundation.e.apps.data.application.data.Application +import foundation.e.apps.data.enums.Source import foundation.e.apps.data.search.CleanApkSearchParams import foundation.e.apps.data.search.SearchPagingRepository import foundation.e.apps.ui.applicationlist.ApplicationDiffUtil -import foundation.e.apps.ui.search.v2.SearchTabType import foundation.e.apps.util.MainCoroutineRule import io.mockk.every import io.mockk.mockk @@ -59,12 +59,12 @@ class CleanApkSearchPagingUseCaseTest { @Test fun `blank query emits empty paging data`() = runTest(mainCoroutineRule.testDispatcher) { val requests = MutableStateFlow( - SearchRequest(query = "", visibleTabs = listOf(SearchTabType.OPEN_SOURCE), version = 1) + SearchRequest(query = "", enabledSources = listOf(Source.OPEN_SOURCE), version = 1) ) val pagingData = useCase( requests = requests, - tab = SearchTabType.OPEN_SOURCE, + source = Source.OPEN_SOURCE, appSource = "cleanapk", appType = "apps", ).first() @@ -78,12 +78,12 @@ class CleanApkSearchPagingUseCaseTest { @Test fun `hidden tab emits empty paging data`() = runTest(mainCoroutineRule.testDispatcher) { val requests = MutableStateFlow( - SearchRequest(query = "notes", visibleTabs = emptyList(), version = 1) + SearchRequest(query = "notes", enabledSources = emptyList(), version = 1) ) val pagingData = useCase( requests = requests, - tab = SearchTabType.OPEN_SOURCE, + source = Source.OPEN_SOURCE, appSource = "cleanapk", appType = "apps", ).first() @@ -101,12 +101,12 @@ class CleanApkSearchPagingUseCaseTest { PagingData.empty() ) val requests = MutableStateFlow( - SearchRequest(query = "notes", visibleTabs = listOf(SearchTabType.OPEN_SOURCE), version = 2) + SearchRequest(query = "notes", enabledSources = listOf(Source.OPEN_SOURCE), version = 2) ) useCase( requests = requests, - tab = SearchTabType.OPEN_SOURCE, + source = Source.OPEN_SOURCE, appSource = "cleanapk", appType = "apps", ).first() diff --git a/app/src/test/java/foundation/e/apps/domain/search/PlayStoreSearchPagingUseCaseTest.kt b/app/src/test/java/foundation/e/apps/domain/search/PlayStoreSearchPagingUseCaseTest.kt index 89cb8aa7c..a79a5d0a5 100644 --- a/app/src/test/java/foundation/e/apps/domain/search/PlayStoreSearchPagingUseCaseTest.kt +++ b/app/src/test/java/foundation/e/apps/domain/search/PlayStoreSearchPagingUseCaseTest.kt @@ -24,9 +24,9 @@ import androidx.recyclerview.widget.ListUpdateCallback import com.aurora.gplayapi.data.models.App import com.google.common.truth.Truth.assertThat import foundation.e.apps.data.application.data.Application +import foundation.e.apps.data.enums.Source import foundation.e.apps.data.search.PlayStorePagingRepository import foundation.e.apps.ui.applicationlist.ApplicationDiffUtil -import foundation.e.apps.ui.search.v2.SearchTabType import foundation.e.apps.util.MainCoroutineRule import io.mockk.every import io.mockk.mockk @@ -60,7 +60,7 @@ class PlayStoreSearchPagingUseCaseTest { @Test fun `blank query emits empty paging data`() = runTest(mainCoroutineRule.testDispatcher) { val requests = MutableStateFlow( - SearchRequest(query = "", visibleTabs = listOf(SearchTabType.COMMON_APPS), version = 1) + SearchRequest(query = "", enabledSources = listOf(Source.PLAY_STORE), version = 1) ) val pagingData = useCase(requests, pageSize = 20).first() @@ -73,7 +73,7 @@ class PlayStoreSearchPagingUseCaseTest { @Test fun `hidden tab emits empty paging data`() = runTest(mainCoroutineRule.testDispatcher) { val requests = MutableStateFlow( - SearchRequest(query = "apps", visibleTabs = emptyList(), version = 1) + SearchRequest(query = "apps", enabledSources = emptyList(), version = 1) ) val pagingData = useCase(requests, pageSize = 20).first() @@ -92,7 +92,7 @@ class PlayStoreSearchPagingUseCaseTest { ) every { playStoreAppMapper.map(app) } returns mapped val requests = MutableStateFlow( - SearchRequest(query = "apps", visibleTabs = listOf(SearchTabType.COMMON_APPS), version = 2) + SearchRequest(query = "apps", enabledSources = listOf(Source.PLAY_STORE), version = 2) ) val pagingData = useCase(requests, pageSize = 20).first() @@ -108,14 +108,14 @@ class PlayStoreSearchPagingUseCaseTest { PagingData.empty() ) val requests = MutableStateFlow( - SearchRequest(query = "apps", visibleTabs = listOf(SearchTabType.COMMON_APPS), version = 1) + SearchRequest(query = "apps", enabledSources = listOf(Source.PLAY_STORE), version = 1) ) val flow = useCase(requests, pageSize = 20) flow.first() requests.value = SearchRequest( query = "apps", - visibleTabs = listOf(SearchTabType.COMMON_APPS), + enabledSources = listOf(Source.PLAY_STORE), version = 2, ) flow.first() diff --git a/app/src/test/java/foundation/e/apps/domain/search/PrepareSearchSubmissionUseCaseTest.kt b/app/src/test/java/foundation/e/apps/domain/search/PrepareSearchSubmissionUseCaseTest.kt index 29ce57d82..a8a916547 100644 --- a/app/src/test/java/foundation/e/apps/domain/search/PrepareSearchSubmissionUseCaseTest.kt +++ b/app/src/test/java/foundation/e/apps/domain/search/PrepareSearchSubmissionUseCaseTest.kt @@ -19,7 +19,7 @@ package foundation.e.apps.domain.search import com.google.common.truth.Truth.assertThat -import foundation.e.apps.ui.search.v2.SearchTabType +import foundation.e.apps.data.enums.Source import io.mockk.every import io.mockk.mockk import org.junit.Before @@ -27,22 +27,22 @@ import org.junit.Test class PrepareSearchSubmissionUseCaseTest { - private lateinit var resolveVisibleSearchTabsUseCase: ResolveVisibleSearchTabsUseCase + private lateinit var resolveEnabledSearchSourcesUseCase: ResolveEnabledSearchSourcesUseCase private lateinit var useCase: PrepareSearchSubmissionUseCase @Before fun setUp() { - resolveVisibleSearchTabsUseCase = mockk() - useCase = PrepareSearchSubmissionUseCase(resolveVisibleSearchTabsUseCase) + resolveEnabledSearchSourcesUseCase = mockk() + useCase = PrepareSearchSubmissionUseCase(resolveEnabledSearchSourcesUseCase) } @Test fun `blank submission does not increment version or create request`() { - every { resolveVisibleSearchTabsUseCase() } returns listOf(SearchTabType.COMMON_APPS) + every { resolveEnabledSearchSourcesUseCase() } returns listOf(Source.PLAY_STORE) val result = useCase( submittedQuery = " ", - selectedTab = SearchTabType.COMMON_APPS, + selectedSource = Source.PLAY_STORE, currentVersion = 3, ) @@ -50,47 +50,47 @@ class PrepareSearchSubmissionUseCaseTest { assertThat(result.nextVersion).isEqualTo(3) assertThat(result.searchRequest).isNull() assertThat(result.hasSubmittedSearch).isFalse() - assertThat(result.selectedTab).isEqualTo(SearchTabType.COMMON_APPS) + assertThat(result.selectedSource).isEqualTo(Source.PLAY_STORE) } @Test - fun `non-blank submission increments version without visible tabs`() { - every { resolveVisibleSearchTabsUseCase() } returns emptyList() + fun `non-blank submission increments version without enabled sources`() { + every { resolveEnabledSearchSourcesUseCase() } returns emptyList() val result = useCase( submittedQuery = "apps", - selectedTab = SearchTabType.OPEN_SOURCE, + selectedSource = Source.OPEN_SOURCE, currentVersion = 2, ) assertThat(result.nextVersion).isEqualTo(3) - assertThat(result.visibleTabs).isEmpty() + assertThat(result.enabledSources).isEmpty() assertThat(result.searchRequest).isNull() assertThat(result.hasSubmittedSearch).isFalse() - assertThat(result.selectedTab).isNull() + assertThat(result.selectedSource).isNull() } @Test - fun `valid submission returns request with resolved tab`() { - every { resolveVisibleSearchTabsUseCase() } returns listOf( - SearchTabType.COMMON_APPS, - SearchTabType.OPEN_SOURCE, + fun `valid submission returns request with resolved source`() { + every { resolveEnabledSearchSourcesUseCase() } returns listOf( + Source.PLAY_STORE, + Source.OPEN_SOURCE, ) val result = useCase( submittedQuery = " notes ", - selectedTab = SearchTabType.PWA, + selectedSource = Source.PWA, currentVersion = 1, ) assertThat(result.nextVersion).isEqualTo(2) - assertThat(result.selectedTab).isEqualTo(SearchTabType.COMMON_APPS) + assertThat(result.selectedSource).isEqualTo(Source.PLAY_STORE) assertThat(result.hasSubmittedSearch).isTrue() assertThat(result.searchRequest).isNotNull() assertThat(result.searchRequest?.query).isEqualTo("notes") - assertThat(result.searchRequest?.visibleTabs).containsExactly( - SearchTabType.COMMON_APPS, - SearchTabType.OPEN_SOURCE, + assertThat(result.searchRequest?.enabledSources).containsExactly( + Source.PLAY_STORE, + Source.OPEN_SOURCE, ).inOrder() } } diff --git a/app/src/test/java/foundation/e/apps/domain/search/ResolveVisibleSearchTabsUseCaseTest.kt b/app/src/test/java/foundation/e/apps/domain/search/ResolveEnabledSearchSourcesUseCaseTest.kt similarity index 82% rename from app/src/test/java/foundation/e/apps/domain/search/ResolveVisibleSearchTabsUseCaseTest.kt rename to app/src/test/java/foundation/e/apps/domain/search/ResolveEnabledSearchSourcesUseCaseTest.kt index bd13d6d8f..98fde8526 100644 --- a/app/src/test/java/foundation/e/apps/domain/search/ResolveVisibleSearchTabsUseCaseTest.kt +++ b/app/src/test/java/foundation/e/apps/domain/search/ResolveEnabledSearchSourcesUseCaseTest.kt @@ -21,29 +21,29 @@ package foundation.e.apps.domain.search import com.google.common.truth.Truth.assertThat import foundation.e.apps.data.StoreRepository import foundation.e.apps.data.Stores +import foundation.e.apps.data.enums.Source import foundation.e.apps.data.enums.Source.OPEN_SOURCE import foundation.e.apps.data.enums.Source.PLAY_STORE import foundation.e.apps.data.enums.Source.PWA import foundation.e.apps.data.enums.Source.SYSTEM_APP -import foundation.e.apps.ui.search.v2.SearchTabType import io.mockk.every import io.mockk.mockk import org.junit.Before import org.junit.Test -class ResolveVisibleSearchTabsUseCaseTest { +class ResolveEnabledSearchSourcesUseCaseTest { private lateinit var stores: Stores - private lateinit var useCase: ResolveVisibleSearchTabsUseCase + private lateinit var useCase: ResolveEnabledSearchSourcesUseCase @Before fun setUp() { stores = mockk() - useCase = ResolveVisibleSearchTabsUseCase(stores) + useCase = ResolveEnabledSearchSourcesUseCase(stores) } @Test - fun `maps enabled stores to tabs in order`() { + fun `maps enabled stores to sources in order`() { val storeRepository = mockk() every { stores.getStores() } returns linkedMapOf( PLAY_STORE to storeRepository, @@ -54,9 +54,9 @@ class ResolveVisibleSearchTabsUseCaseTest { val result = useCase() assertThat(result).containsExactly( - SearchTabType.COMMON_APPS, - SearchTabType.OPEN_SOURCE, - SearchTabType.PWA, + Source.PLAY_STORE, + Source.OPEN_SOURCE, + Source.PWA, ).inOrder() } @@ -70,7 +70,7 @@ class ResolveVisibleSearchTabsUseCaseTest { val result = useCase() - assertThat(result).containsExactly(SearchTabType.OPEN_SOURCE) + assertThat(result).containsExactly(Source.OPEN_SOURCE) } @Test diff --git a/app/src/test/java/foundation/e/apps/domain/search/UpdateSearchForStoreSelectionUseCaseTest.kt b/app/src/test/java/foundation/e/apps/domain/search/UpdateSearchForStoreSelectionUseCaseTest.kt index 1508eb0ec..4f2be6cc0 100644 --- a/app/src/test/java/foundation/e/apps/domain/search/UpdateSearchForStoreSelectionUseCaseTest.kt +++ b/app/src/test/java/foundation/e/apps/domain/search/UpdateSearchForStoreSelectionUseCaseTest.kt @@ -19,8 +19,8 @@ package foundation.e.apps.domain.search import com.google.common.truth.Truth.assertThat +import foundation.e.apps.data.enums.Source import foundation.e.apps.data.preference.AppLoungePreference -import foundation.e.apps.ui.search.v2.SearchTabType import io.mockk.every import io.mockk.mockk import org.junit.Before @@ -28,31 +28,31 @@ import org.junit.Test class UpdateSearchForStoreSelectionUseCaseTest { - private lateinit var resolveVisibleSearchTabsUseCase: ResolveVisibleSearchTabsUseCase + private lateinit var resolveEnabledSearchSourcesUseCase: ResolveEnabledSearchSourcesUseCase private lateinit var appLoungePreference: AppLoungePreference private lateinit var useCase: UpdateSearchForStoreSelectionUseCase @Before fun setUp() { - resolveVisibleSearchTabsUseCase = mockk() + resolveEnabledSearchSourcesUseCase = mockk() appLoungePreference = mockk() useCase = UpdateSearchForStoreSelectionUseCase( - resolveVisibleSearchTabsUseCase, + resolveEnabledSearchSourcesUseCase, appLoungePreference, ) } @Test - fun `submitted search updates request when visible tabs are available`() { - every { resolveVisibleSearchTabsUseCase() } returns listOf( - SearchTabType.COMMON_APPS, - SearchTabType.PWA, + fun `submitted search updates request when enabled sources are available`() { + every { resolveEnabledSearchSourcesUseCase() } returns listOf( + Source.PLAY_STORE, + Source.PWA, ) every { appLoungePreference.isPlayStoreSelected() } returns true val result = useCase( currentQuery = "apps", - selectedTab = SearchTabType.PWA, + selectedSource = Source.PWA, hasSubmittedSearch = true, currentVersion = 4, ) @@ -60,19 +60,19 @@ class UpdateSearchForStoreSelectionUseCaseTest { assertThat(result.searchRequest).isNotNull() assertThat(result.searchRequest?.query).isEqualTo("apps") assertThat(result.searchRequest?.version).isEqualTo(4) - assertThat(result.selectedTab).isEqualTo(SearchTabType.PWA) + assertThat(result.selectedSource).isEqualTo(Source.PWA) assertThat(result.hasSubmittedSearch).isTrue() assertThat(result.suggestionsEnabled).isTrue() } @Test fun `no submitted search emits empty query request`() { - every { resolveVisibleSearchTabsUseCase() } returns listOf(SearchTabType.OPEN_SOURCE) + every { resolveEnabledSearchSourcesUseCase() } returns listOf(Source.OPEN_SOURCE) every { appLoungePreference.isPlayStoreSelected() } returns false val result = useCase( currentQuery = "", - selectedTab = SearchTabType.OPEN_SOURCE, + selectedSource = Source.OPEN_SOURCE, hasSubmittedSearch = false, currentVersion = 2, ) @@ -82,23 +82,23 @@ class UpdateSearchForStoreSelectionUseCaseTest { assertThat(result.searchRequest?.version).isEqualTo(2) assertThat(result.hasSubmittedSearch).isFalse() assertThat(result.suggestionsEnabled).isFalse() - assertThat(result.selectedTab).isEqualTo(SearchTabType.OPEN_SOURCE) + assertThat(result.selectedSource).isEqualTo(Source.OPEN_SOURCE) } @Test - fun `empty visible tabs clear selection and skip request update`() { - every { resolveVisibleSearchTabsUseCase() } returns emptyList() + fun `empty enabled sources clear selection and skip request update`() { + every { resolveEnabledSearchSourcesUseCase() } returns emptyList() every { appLoungePreference.isPlayStoreSelected() } returns true val result = useCase( currentQuery = "apps", - selectedTab = SearchTabType.COMMON_APPS, + selectedSource = Source.PLAY_STORE, hasSubmittedSearch = true, currentVersion = 1, ) - assertThat(result.visibleTabs).isEmpty() - assertThat(result.selectedTab).isNull() + assertThat(result.enabledSources).isEmpty() + assertThat(result.selectedSource).isNull() assertThat(result.hasSubmittedSearch).isFalse() assertThat(result.searchRequest).isNull() } diff --git a/app/src/test/java/foundation/e/apps/ui/search/v2/SearchViewModelV2Test.kt b/app/src/test/java/foundation/e/apps/ui/search/v2/SearchViewModelV2Test.kt index aefa3a66f..a416f91bb 100644 --- a/app/src/test/java/foundation/e/apps/ui/search/v2/SearchViewModelV2Test.kt +++ b/app/src/test/java/foundation/e/apps/ui/search/v2/SearchViewModelV2Test.kt @@ -40,7 +40,7 @@ import foundation.e.apps.domain.search.FetchSearchSuggestionsUseCase import foundation.e.apps.domain.search.PlayStoreAppMapper import foundation.e.apps.domain.search.PlayStoreSearchPagingUseCase import foundation.e.apps.domain.search.PrepareSearchSubmissionUseCase -import foundation.e.apps.domain.search.ResolveVisibleSearchTabsUseCase +import foundation.e.apps.domain.search.ResolveEnabledSearchSourcesUseCase import foundation.e.apps.domain.search.UpdateSearchForStoreSelectionUseCase import foundation.e.apps.ui.applicationlist.ApplicationDiffUtil import foundation.e.apps.ui.compose.state.InstallStatusReconciler @@ -79,7 +79,7 @@ class SearchViewModelV2Test { private lateinit var playStoreAppMapper: PlayStoreAppMapper private lateinit var cleanApkSearchPagingUseCase: CleanApkSearchPagingUseCase private lateinit var playStoreSearchPagingUseCase: PlayStoreSearchPagingUseCase - private lateinit var resolveVisibleSearchTabsUseCase: ResolveVisibleSearchTabsUseCase + private lateinit var resolveEnabledSearchSourcesUseCase: ResolveEnabledSearchSourcesUseCase private lateinit var fetchSearchSuggestionsUseCase: FetchSearchSuggestionsUseCase private lateinit var prepareSearchSubmissionUseCase: PrepareSearchSubmissionUseCase private lateinit var updateSearchForStoreSelectionUseCase: UpdateSearchForStoreSelectionUseCase @@ -621,18 +621,18 @@ class SearchViewModelV2Test { private fun buildViewModel() { stores = buildStores() - resolveVisibleSearchTabsUseCase = ResolveVisibleSearchTabsUseCase(stores) + resolveEnabledSearchSourcesUseCase = ResolveEnabledSearchSourcesUseCase(stores) fetchSearchSuggestionsUseCase = FetchSearchSuggestionsUseCase(suggestionSource, preference) - prepareSearchSubmissionUseCase = PrepareSearchSubmissionUseCase(resolveVisibleSearchTabsUseCase) + prepareSearchSubmissionUseCase = PrepareSearchSubmissionUseCase(resolveEnabledSearchSourcesUseCase) updateSearchForStoreSelectionUseCase = UpdateSearchForStoreSelectionUseCase( - resolveVisibleSearchTabsUseCase, + resolveEnabledSearchSourcesUseCase, preference, ) viewModel = SearchViewModelV2( preference, cleanApkSearchPagingUseCase, playStoreSearchPagingUseCase, - resolveVisibleSearchTabsUseCase, + resolveEnabledSearchSourcesUseCase, fetchSearchSuggestionsUseCase, prepareSearchSubmissionUseCase, updateSearchForStoreSelectionUseCase, -- GitLab From 58dcdb9c9954cdc9bd9b970bde2e616fd3221524 Mon Sep 17 00:00:00 2001 From: Fahim Masud Choudhury Date: Mon, 2 Feb 2026 12:14:31 +0600 Subject: [PATCH 09/17] refactor: centralize enabled search sources in Stores Replace the ResolveEnabledSearchSourcesUseCase with a single Stores API so search selection logic stays consistent across use cases and the ViewModel, and update tests accordingly. --- .../java/foundation/e/apps/data/Stores.kt | 7 ++ .../search/PrepareSearchSubmissionUseCase.kt | 5 +- .../ResolveEnabledSearchSourcesUseCase.kt | 39 --------- .../UpdateSearchForStoreSelectionUseCase.kt | 5 +- .../e/apps/ui/search/v2/SearchViewModelV2.kt | 8 +- .../java/foundation/e/apps/data/StoresTest.kt | 65 ++++++++++++++ .../PrepareSearchSubmissionUseCaseTest.kt | 13 +-- .../ResolveEnabledSearchSourcesUseCaseTest.kt | 84 ------------------- ...pdateSearchForStoreSelectionUseCaseTest.kt | 13 +-- .../ui/search/v2/SearchViewModelV2Test.kt | 8 +- 10 files changed, 97 insertions(+), 150 deletions(-) delete mode 100644 app/src/main/java/foundation/e/apps/domain/search/ResolveEnabledSearchSourcesUseCase.kt delete mode 100644 app/src/test/java/foundation/e/apps/domain/search/ResolveEnabledSearchSourcesUseCaseTest.kt diff --git a/app/src/main/java/foundation/e/apps/data/Stores.kt b/app/src/main/java/foundation/e/apps/data/Stores.kt index ed6f48ec6..ca076e86c 100644 --- a/app/src/main/java/foundation/e/apps/data/Stores.kt +++ b/app/src/main/java/foundation/e/apps/data/Stores.kt @@ -49,6 +49,8 @@ class Stores @Inject constructor( appLoungePreference ) + private val searchEligibleSources = storeConfigs.keys + private val _enabledStoresFlow = MutableStateFlow(provideEnabledStores()) val enabledStoresFlow: StateFlow> = _enabledStoresFlow.asStateFlow() @@ -64,6 +66,11 @@ class Stores @Inject constructor( .mapValues { it.value.repository } } + fun getEnabledSearchSources(): List = + storeConfigs + .filter { (source, config) -> source in searchEligibleSources && config.isEnabled() } + .map { (source, _) -> source } + fun getStore(source: Source): StoreRepository? = getStores()[source] fun enableStore(source: Source) { diff --git a/app/src/main/java/foundation/e/apps/domain/search/PrepareSearchSubmissionUseCase.kt b/app/src/main/java/foundation/e/apps/domain/search/PrepareSearchSubmissionUseCase.kt index b01787f74..d798460a7 100644 --- a/app/src/main/java/foundation/e/apps/domain/search/PrepareSearchSubmissionUseCase.kt +++ b/app/src/main/java/foundation/e/apps/domain/search/PrepareSearchSubmissionUseCase.kt @@ -18,11 +18,12 @@ package foundation.e.apps.domain.search +import foundation.e.apps.data.Stores import foundation.e.apps.data.enums.Source import javax.inject.Inject class PrepareSearchSubmissionUseCase @Inject constructor( - private val resolveEnabledSearchSourcesUseCase: ResolveEnabledSearchSourcesUseCase, + private val stores: Stores, ) { operator fun invoke( submittedQuery: String, @@ -30,7 +31,7 @@ class PrepareSearchSubmissionUseCase @Inject constructor( currentVersion: Int, ): SearchSubmissionResult { val trimmedQuery = submittedQuery.trim() - val enabledSources = resolveEnabledSearchSourcesUseCase() + val enabledSources = stores.getEnabledSearchSources() val resolvedSelectedSource = selectedSource?.takeIf { enabledSources.contains(it) } ?: enabledSources.firstOrNull() val shouldIncrementVersion = trimmedQuery.isNotEmpty() diff --git a/app/src/main/java/foundation/e/apps/domain/search/ResolveEnabledSearchSourcesUseCase.kt b/app/src/main/java/foundation/e/apps/domain/search/ResolveEnabledSearchSourcesUseCase.kt deleted file mode 100644 index eda74b883..000000000 --- a/app/src/main/java/foundation/e/apps/domain/search/ResolveEnabledSearchSourcesUseCase.kt +++ /dev/null @@ -1,39 +0,0 @@ -/* - * 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.domain.search - -import foundation.e.apps.data.Stores -import foundation.e.apps.data.enums.Source -import foundation.e.apps.data.enums.Source.OPEN_SOURCE -import foundation.e.apps.data.enums.Source.PLAY_STORE -import foundation.e.apps.data.enums.Source.PWA -import javax.inject.Inject - -class ResolveEnabledSearchSourcesUseCase @Inject constructor( - private val stores: Stores, -) { - operator fun invoke(): List { - return stores.getStores().mapNotNull { (key, _) -> - when (key) { - PLAY_STORE, OPEN_SOURCE, PWA -> key - else -> null - } - } - } -} diff --git a/app/src/main/java/foundation/e/apps/domain/search/UpdateSearchForStoreSelectionUseCase.kt b/app/src/main/java/foundation/e/apps/domain/search/UpdateSearchForStoreSelectionUseCase.kt index cc2cb8bf6..9d10ebf56 100644 --- a/app/src/main/java/foundation/e/apps/domain/search/UpdateSearchForStoreSelectionUseCase.kt +++ b/app/src/main/java/foundation/e/apps/domain/search/UpdateSearchForStoreSelectionUseCase.kt @@ -18,12 +18,13 @@ package foundation.e.apps.domain.search +import foundation.e.apps.data.Stores import foundation.e.apps.data.enums.Source import foundation.e.apps.data.preference.AppLoungePreference import javax.inject.Inject class UpdateSearchForStoreSelectionUseCase @Inject constructor( - private val resolveEnabledSearchSourcesUseCase: ResolveEnabledSearchSourcesUseCase, + private val stores: Stores, private val appLoungePreference: AppLoungePreference, ) { operator fun invoke( @@ -32,7 +33,7 @@ class UpdateSearchForStoreSelectionUseCase @Inject constructor( hasSubmittedSearch: Boolean, currentVersion: Int, ): StoreSelectionUpdate { - val enabledSources = resolveEnabledSearchSourcesUseCase() + val enabledSources = stores.getEnabledSearchSources() val resolvedSelectedSource = selectedSource?.takeIf { enabledSources.contains(it) } ?: enabledSources.firstOrNull() val updatedHasSubmittedSearch = hasSubmittedSearch && enabledSources.isNotEmpty() diff --git a/app/src/main/java/foundation/e/apps/ui/search/v2/SearchViewModelV2.kt b/app/src/main/java/foundation/e/apps/ui/search/v2/SearchViewModelV2.kt index ce4c277cf..eb9f265eb 100644 --- a/app/src/main/java/foundation/e/apps/ui/search/v2/SearchViewModelV2.kt +++ b/app/src/main/java/foundation/e/apps/ui/search/v2/SearchViewModelV2.kt @@ -34,7 +34,6 @@ import foundation.e.apps.domain.search.CleanApkSearchPagingUseCase import foundation.e.apps.domain.search.FetchSearchSuggestionsUseCase import foundation.e.apps.domain.search.PlayStoreSearchPagingUseCase import foundation.e.apps.domain.search.PrepareSearchSubmissionUseCase -import foundation.e.apps.domain.search.ResolveEnabledSearchSourcesUseCase import foundation.e.apps.domain.search.SearchRequest import foundation.e.apps.domain.search.UpdateSearchForStoreSelectionUseCase import foundation.e.apps.install.download.data.DownloadProgress @@ -86,7 +85,6 @@ class SearchViewModelV2 @Inject constructor( private val appLoungePreference: AppLoungePreference, cleanApkSearchPagingUseCase: CleanApkSearchPagingUseCase, playStoreSearchPagingUseCase: PlayStoreSearchPagingUseCase, - private val resolveEnabledSearchSourcesUseCase: ResolveEnabledSearchSourcesUseCase, private val fetchSearchSuggestionsUseCase: FetchSearchSuggestionsUseCase, private val prepareSearchSubmissionUseCase: PrepareSearchSubmissionUseCase, private val updateSearchForStoreSelectionUseCase: UpdateSearchForStoreSelectionUseCase, @@ -95,7 +93,7 @@ class SearchViewModelV2 @Inject constructor( private val installStatusReconciler: InstallStatusReconciler, ) : ViewModel() { - private val initialVisibleTabs = resolveEnabledSearchSourcesUseCase().toSearchTabTypes() + private val initialVisibleTabs = stores.getEnabledSearchSources().toSearchTabTypes() private val _uiState = MutableStateFlow( SearchUiState( @@ -150,7 +148,7 @@ class SearchViewModelV2 @Inject constructor( suggestionJob?.cancel() if (newQuery.isBlank()) { - val visibleTabs = resolveEnabledSearchSourcesUseCase().toSearchTabTypes() + val visibleTabs = stores.getEnabledSearchSources().toSearchTabTypes() _uiState.update { current -> SearchUiStateReducer.reduceQueryCleared(current, visibleTabs) } @@ -185,7 +183,7 @@ class SearchViewModelV2 @Inject constructor( fun onQueryCleared() { suggestionJob?.cancel() - val visibleTabs = resolveEnabledSearchSourcesUseCase().toSearchTabTypes() + val visibleTabs = stores.getEnabledSearchSources().toSearchTabTypes() _uiState.update { current -> SearchUiStateReducer.reduceQueryCleared(current, visibleTabs) } diff --git a/app/src/test/java/foundation/e/apps/data/StoresTest.kt b/app/src/test/java/foundation/e/apps/data/StoresTest.kt index 463583143..674f97796 100644 --- a/app/src/test/java/foundation/e/apps/data/StoresTest.kt +++ b/app/src/test/java/foundation/e/apps/data/StoresTest.kt @@ -52,6 +52,65 @@ class StoresTest { assertThat(result[Source.PWA]).isSameInstanceAs(cleanApkPwaRepository) } + @Test + fun getEnabledSearchSourcesReturnsOrderedEnabledSources() { + playStoreSelected = true + openSourceSelected = true + pwaSelected = true + + val result = stores.getEnabledSearchSources() + + assertThat(result) + .isEqualTo(listOf(Source.PLAY_STORE, Source.OPEN_SOURCE, Source.PWA)) + } + + @Test + fun getEnabledSearchSourcesReturnsSingleEnabledSource() { + playStoreSelected = false + openSourceSelected = true + pwaSelected = false + + val result = stores.getEnabledSearchSources() + + assertThat(result).isEqualTo(listOf(Source.OPEN_SOURCE)) + } + + @Test + fun getEnabledSearchSourcesReturnsEmptyWhenNoneEnabled() { + playStoreSelected = false + openSourceSelected = false + pwaSelected = false + + val result = stores.getEnabledSearchSources() + + assertThat(result).isEqualTo(emptyList()) + } + + @Test + fun getEnabledSearchSourcesFiltersNonSearchSources() { + val systemRepository: StoreRepository = mockk(relaxed = true) + val configs = linkedMapOf( + Source.PLAY_STORE to StoreConfig( + repository = playStoreRepository, + isEnabled = { true }, + enable = {}, + disable = {}, + ), + Source.SYSTEM_APP to StoreConfig( + repository = systemRepository, + isEnabled = { true }, + enable = {}, + disable = {}, + ), + ) + + overrideStoreConfigsForTest(configs) + + val result = stores.getEnabledSearchSources() + + assertThat(result).isEqualTo(listOf(Source.PLAY_STORE)) + } + @Test fun enableAndDisableStoreProxiesPreference() { stores.enableStore(Source.OPEN_SOURCE) @@ -114,4 +173,10 @@ class StoresTest { preference, ) } + + private fun overrideStoreConfigsForTest(configs: Map) { + val field = Stores::class.java.getDeclaredField("storeConfigs") + field.isAccessible = true + field.set(stores, configs) + } } diff --git a/app/src/test/java/foundation/e/apps/domain/search/PrepareSearchSubmissionUseCaseTest.kt b/app/src/test/java/foundation/e/apps/domain/search/PrepareSearchSubmissionUseCaseTest.kt index a8a916547..f8bba4590 100644 --- a/app/src/test/java/foundation/e/apps/domain/search/PrepareSearchSubmissionUseCaseTest.kt +++ b/app/src/test/java/foundation/e/apps/domain/search/PrepareSearchSubmissionUseCaseTest.kt @@ -19,6 +19,7 @@ package foundation.e.apps.domain.search import com.google.common.truth.Truth.assertThat +import foundation.e.apps.data.Stores import foundation.e.apps.data.enums.Source import io.mockk.every import io.mockk.mockk @@ -27,18 +28,18 @@ import org.junit.Test class PrepareSearchSubmissionUseCaseTest { - private lateinit var resolveEnabledSearchSourcesUseCase: ResolveEnabledSearchSourcesUseCase + private lateinit var stores: Stores private lateinit var useCase: PrepareSearchSubmissionUseCase @Before fun setUp() { - resolveEnabledSearchSourcesUseCase = mockk() - useCase = PrepareSearchSubmissionUseCase(resolveEnabledSearchSourcesUseCase) + stores = mockk() + useCase = PrepareSearchSubmissionUseCase(stores) } @Test fun `blank submission does not increment version or create request`() { - every { resolveEnabledSearchSourcesUseCase() } returns listOf(Source.PLAY_STORE) + every { stores.getEnabledSearchSources() } returns listOf(Source.PLAY_STORE) val result = useCase( submittedQuery = " ", @@ -55,7 +56,7 @@ class PrepareSearchSubmissionUseCaseTest { @Test fun `non-blank submission increments version without enabled sources`() { - every { resolveEnabledSearchSourcesUseCase() } returns emptyList() + every { stores.getEnabledSearchSources() } returns emptyList() val result = useCase( submittedQuery = "apps", @@ -72,7 +73,7 @@ class PrepareSearchSubmissionUseCaseTest { @Test fun `valid submission returns request with resolved source`() { - every { resolveEnabledSearchSourcesUseCase() } returns listOf( + every { stores.getEnabledSearchSources() } returns listOf( Source.PLAY_STORE, Source.OPEN_SOURCE, ) diff --git a/app/src/test/java/foundation/e/apps/domain/search/ResolveEnabledSearchSourcesUseCaseTest.kt b/app/src/test/java/foundation/e/apps/domain/search/ResolveEnabledSearchSourcesUseCaseTest.kt deleted file mode 100644 index 98fde8526..000000000 --- a/app/src/test/java/foundation/e/apps/domain/search/ResolveEnabledSearchSourcesUseCaseTest.kt +++ /dev/null @@ -1,84 +0,0 @@ -/* - * 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.domain.search - -import com.google.common.truth.Truth.assertThat -import foundation.e.apps.data.StoreRepository -import foundation.e.apps.data.Stores -import foundation.e.apps.data.enums.Source -import foundation.e.apps.data.enums.Source.OPEN_SOURCE -import foundation.e.apps.data.enums.Source.PLAY_STORE -import foundation.e.apps.data.enums.Source.PWA -import foundation.e.apps.data.enums.Source.SYSTEM_APP -import io.mockk.every -import io.mockk.mockk -import org.junit.Before -import org.junit.Test - -class ResolveEnabledSearchSourcesUseCaseTest { - - private lateinit var stores: Stores - private lateinit var useCase: ResolveEnabledSearchSourcesUseCase - - @Before - fun setUp() { - stores = mockk() - useCase = ResolveEnabledSearchSourcesUseCase(stores) - } - - @Test - fun `maps enabled stores to sources in order`() { - val storeRepository = mockk() - every { stores.getStores() } returns linkedMapOf( - PLAY_STORE to storeRepository, - OPEN_SOURCE to storeRepository, - PWA to storeRepository, - ) - - val result = useCase() - - assertThat(result).containsExactly( - Source.PLAY_STORE, - Source.OPEN_SOURCE, - Source.PWA, - ).inOrder() - } - - @Test - fun `ignores unsupported store sources`() { - val storeRepository = mockk() - every { stores.getStores() } returns linkedMapOf( - SYSTEM_APP to storeRepository, - OPEN_SOURCE to storeRepository, - ) - - val result = useCase() - - assertThat(result).containsExactly(Source.OPEN_SOURCE) - } - - @Test - fun `returns empty list when no stores are enabled`() { - every { stores.getStores() } returns emptyMap() - - val result = useCase() - - assertThat(result).isEmpty() - } -} diff --git a/app/src/test/java/foundation/e/apps/domain/search/UpdateSearchForStoreSelectionUseCaseTest.kt b/app/src/test/java/foundation/e/apps/domain/search/UpdateSearchForStoreSelectionUseCaseTest.kt index 4f2be6cc0..fdb68dfb7 100644 --- a/app/src/test/java/foundation/e/apps/domain/search/UpdateSearchForStoreSelectionUseCaseTest.kt +++ b/app/src/test/java/foundation/e/apps/domain/search/UpdateSearchForStoreSelectionUseCaseTest.kt @@ -19,6 +19,7 @@ package foundation.e.apps.domain.search import com.google.common.truth.Truth.assertThat +import foundation.e.apps.data.Stores import foundation.e.apps.data.enums.Source import foundation.e.apps.data.preference.AppLoungePreference import io.mockk.every @@ -28,23 +29,23 @@ import org.junit.Test class UpdateSearchForStoreSelectionUseCaseTest { - private lateinit var resolveEnabledSearchSourcesUseCase: ResolveEnabledSearchSourcesUseCase + private lateinit var stores: Stores private lateinit var appLoungePreference: AppLoungePreference private lateinit var useCase: UpdateSearchForStoreSelectionUseCase @Before fun setUp() { - resolveEnabledSearchSourcesUseCase = mockk() + stores = mockk() appLoungePreference = mockk() useCase = UpdateSearchForStoreSelectionUseCase( - resolveEnabledSearchSourcesUseCase, + stores, appLoungePreference, ) } @Test fun `submitted search updates request when enabled sources are available`() { - every { resolveEnabledSearchSourcesUseCase() } returns listOf( + every { stores.getEnabledSearchSources() } returns listOf( Source.PLAY_STORE, Source.PWA, ) @@ -67,7 +68,7 @@ class UpdateSearchForStoreSelectionUseCaseTest { @Test fun `no submitted search emits empty query request`() { - every { resolveEnabledSearchSourcesUseCase() } returns listOf(Source.OPEN_SOURCE) + every { stores.getEnabledSearchSources() } returns listOf(Source.OPEN_SOURCE) every { appLoungePreference.isPlayStoreSelected() } returns false val result = useCase( @@ -87,7 +88,7 @@ class UpdateSearchForStoreSelectionUseCaseTest { @Test fun `empty enabled sources clear selection and skip request update`() { - every { resolveEnabledSearchSourcesUseCase() } returns emptyList() + every { stores.getEnabledSearchSources() } returns emptyList() every { appLoungePreference.isPlayStoreSelected() } returns true val result = useCase( diff --git a/app/src/test/java/foundation/e/apps/ui/search/v2/SearchViewModelV2Test.kt b/app/src/test/java/foundation/e/apps/ui/search/v2/SearchViewModelV2Test.kt index a416f91bb..6cfd30c13 100644 --- a/app/src/test/java/foundation/e/apps/ui/search/v2/SearchViewModelV2Test.kt +++ b/app/src/test/java/foundation/e/apps/ui/search/v2/SearchViewModelV2Test.kt @@ -40,7 +40,6 @@ import foundation.e.apps.domain.search.FetchSearchSuggestionsUseCase import foundation.e.apps.domain.search.PlayStoreAppMapper import foundation.e.apps.domain.search.PlayStoreSearchPagingUseCase import foundation.e.apps.domain.search.PrepareSearchSubmissionUseCase -import foundation.e.apps.domain.search.ResolveEnabledSearchSourcesUseCase import foundation.e.apps.domain.search.UpdateSearchForStoreSelectionUseCase import foundation.e.apps.ui.applicationlist.ApplicationDiffUtil import foundation.e.apps.ui.compose.state.InstallStatusReconciler @@ -79,7 +78,6 @@ class SearchViewModelV2Test { private lateinit var playStoreAppMapper: PlayStoreAppMapper private lateinit var cleanApkSearchPagingUseCase: CleanApkSearchPagingUseCase private lateinit var playStoreSearchPagingUseCase: PlayStoreSearchPagingUseCase - private lateinit var resolveEnabledSearchSourcesUseCase: ResolveEnabledSearchSourcesUseCase private lateinit var fetchSearchSuggestionsUseCase: FetchSearchSuggestionsUseCase private lateinit var prepareSearchSubmissionUseCase: PrepareSearchSubmissionUseCase private lateinit var updateSearchForStoreSelectionUseCase: UpdateSearchForStoreSelectionUseCase @@ -621,18 +619,16 @@ class SearchViewModelV2Test { private fun buildViewModel() { stores = buildStores() - resolveEnabledSearchSourcesUseCase = ResolveEnabledSearchSourcesUseCase(stores) fetchSearchSuggestionsUseCase = FetchSearchSuggestionsUseCase(suggestionSource, preference) - prepareSearchSubmissionUseCase = PrepareSearchSubmissionUseCase(resolveEnabledSearchSourcesUseCase) + prepareSearchSubmissionUseCase = PrepareSearchSubmissionUseCase(stores) updateSearchForStoreSelectionUseCase = UpdateSearchForStoreSelectionUseCase( - resolveEnabledSearchSourcesUseCase, + stores, preference, ) viewModel = SearchViewModelV2( preference, cleanApkSearchPagingUseCase, playStoreSearchPagingUseCase, - resolveEnabledSearchSourcesUseCase, fetchSearchSuggestionsUseCase, prepareSearchSubmissionUseCase, updateSearchForStoreSelectionUseCase, -- GitLab From b1b8c6b0d0cbcfbe97625004d813d7f141576821 Mon Sep 17 00:00:00 2001 From: Fahim Masud Choudhury Date: Mon, 2 Feb 2026 19:07:27 +0600 Subject: [PATCH 10/17] refactor: inline store selection updates in SearchViewModelV2 Removes the dedicated store-selection use case and result type, moving the logic into SearchViewModelV2 so store-change handling stays localized and clears suggestions when Play Store is disabled. Adds a regression test for suggestion clearing and renames the search submission result file to match its class. --- ...teResults.kt => SearchSubmissionResult.kt} | 8 -- .../UpdateSearchForStoreSelectionUseCase.kt | 63 ----------- .../e/apps/ui/search/v2/SearchViewModelV2.kt | 46 +++++--- ...pdateSearchForStoreSelectionUseCaseTest.kt | 106 ------------------ .../ui/search/v2/SearchViewModelV2Test.kt | 26 +++-- 5 files changed, 52 insertions(+), 197 deletions(-) rename app/src/main/java/foundation/e/apps/domain/search/{SearchStateResults.kt => SearchSubmissionResult.kt} (82%) delete mode 100644 app/src/main/java/foundation/e/apps/domain/search/UpdateSearchForStoreSelectionUseCase.kt delete mode 100644 app/src/test/java/foundation/e/apps/domain/search/UpdateSearchForStoreSelectionUseCaseTest.kt diff --git a/app/src/main/java/foundation/e/apps/domain/search/SearchStateResults.kt b/app/src/main/java/foundation/e/apps/domain/search/SearchSubmissionResult.kt similarity index 82% rename from app/src/main/java/foundation/e/apps/domain/search/SearchStateResults.kt rename to app/src/main/java/foundation/e/apps/domain/search/SearchSubmissionResult.kt index ea7f0fddc..81b13870b 100644 --- a/app/src/main/java/foundation/e/apps/domain/search/SearchStateResults.kt +++ b/app/src/main/java/foundation/e/apps/domain/search/SearchSubmissionResult.kt @@ -28,11 +28,3 @@ data class SearchSubmissionResult( val nextVersion: Int, val searchRequest: SearchRequest?, ) - -data class StoreSelectionUpdate( - val enabledSources: List, - val selectedSource: Source?, - val hasSubmittedSearch: Boolean, - val suggestionsEnabled: Boolean, - val searchRequest: SearchRequest?, -) diff --git a/app/src/main/java/foundation/e/apps/domain/search/UpdateSearchForStoreSelectionUseCase.kt b/app/src/main/java/foundation/e/apps/domain/search/UpdateSearchForStoreSelectionUseCase.kt deleted file mode 100644 index 9d10ebf56..000000000 --- a/app/src/main/java/foundation/e/apps/domain/search/UpdateSearchForStoreSelectionUseCase.kt +++ /dev/null @@ -1,63 +0,0 @@ -/* - * 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.domain.search - -import foundation.e.apps.data.Stores -import foundation.e.apps.data.enums.Source -import foundation.e.apps.data.preference.AppLoungePreference -import javax.inject.Inject - -class UpdateSearchForStoreSelectionUseCase @Inject constructor( - private val stores: Stores, - private val appLoungePreference: AppLoungePreference, -) { - operator fun invoke( - currentQuery: String, - selectedSource: Source?, - hasSubmittedSearch: Boolean, - currentVersion: Int, - ): StoreSelectionUpdate { - val enabledSources = stores.getEnabledSearchSources() - val resolvedSelectedSource = selectedSource?.takeIf { enabledSources.contains(it) } - ?: enabledSources.firstOrNull() - val updatedHasSubmittedSearch = hasSubmittedSearch && enabledSources.isNotEmpty() - val shouldUpdateRequest = hasSubmittedSearch && currentQuery.isNotBlank() - val searchRequest = when { - shouldUpdateRequest && enabledSources.isNotEmpty() -> SearchRequest( - query = currentQuery, - enabledSources = enabledSources, - version = currentVersion, - ) - !hasSubmittedSearch -> SearchRequest( - query = "", - enabledSources = enabledSources, - version = currentVersion, - ) - else -> null - } - - return StoreSelectionUpdate( - enabledSources = enabledSources, - selectedSource = resolvedSelectedSource, - hasSubmittedSearch = updatedHasSubmittedSearch, - suggestionsEnabled = appLoungePreference.isPlayStoreSelected(), - searchRequest = searchRequest, - ) - } -} diff --git a/app/src/main/java/foundation/e/apps/ui/search/v2/SearchViewModelV2.kt b/app/src/main/java/foundation/e/apps/ui/search/v2/SearchViewModelV2.kt index eb9f265eb..b093d55ee 100644 --- a/app/src/main/java/foundation/e/apps/ui/search/v2/SearchViewModelV2.kt +++ b/app/src/main/java/foundation/e/apps/ui/search/v2/SearchViewModelV2.kt @@ -35,7 +35,6 @@ import foundation.e.apps.domain.search.FetchSearchSuggestionsUseCase import foundation.e.apps.domain.search.PlayStoreSearchPagingUseCase import foundation.e.apps.domain.search.PrepareSearchSubmissionUseCase import foundation.e.apps.domain.search.SearchRequest -import foundation.e.apps.domain.search.UpdateSearchForStoreSelectionUseCase import foundation.e.apps.install.download.data.DownloadProgress import foundation.e.apps.ui.compose.state.InstallStatusReconciler import foundation.e.apps.ui.compose.state.InstallStatusStream @@ -87,7 +86,6 @@ class SearchViewModelV2 @Inject constructor( playStoreSearchPagingUseCase: PlayStoreSearchPagingUseCase, private val fetchSearchSuggestionsUseCase: FetchSearchSuggestionsUseCase, private val prepareSearchSubmissionUseCase: PrepareSearchSubmissionUseCase, - private val updateSearchForStoreSelectionUseCase: UpdateSearchForStoreSelectionUseCase, private val stores: Stores, private val installStatusStream: InstallStatusStream, private val installStatusReconciler: InstallStatusReconciler, @@ -231,23 +229,45 @@ class SearchViewModelV2 @Inject constructor( private fun handleStoreSelectionChanged() { val currentState = _uiState.value - val update = updateSearchForStoreSelectionUseCase( - currentQuery = currentState.query, - selectedSource = currentState.selectedTab?.toSource(), - hasSubmittedSearch = currentState.hasSubmittedSearch, - currentVersion = currentState.searchVersion, - ) + val enabledSources = stores.getEnabledSearchSources() + val hasEnabledSources = enabledSources.isNotEmpty() + + val selectedSource = currentState.selectedTab?.toSource() + val resolvedSelectedSource = selectedSource + ?.takeIf { enabledSources.contains(it) } + ?: enabledSources.firstOrNull() + + val updatedHasSubmittedSearch = currentState.hasSubmittedSearch && hasEnabledSources + val shouldUpdateRequest = currentState.hasSubmittedSearch && currentState.query.isNotBlank() + + val searchRequest = when { + shouldUpdateRequest && hasEnabledSources -> SearchRequest( + query = currentState.query, + enabledSources = enabledSources, + version = currentState.searchVersion, + ) + !currentState.hasSubmittedSearch -> SearchRequest( + query = "", + enabledSources = enabledSources, + version = currentState.searchVersion, + ) + else -> null + } + + val areSuggestionsEnabled = appLoungePreference.isPlayStoreSelected() _uiState.update { current -> + val updatedSuggestions = if (areSuggestionsEnabled) current.suggestions else emptyList() current.copy( - availableTabs = update.enabledSources.toSearchTabTypes(), - selectedTab = update.selectedSource?.toSearchTabTypeOrNull(), - hasSubmittedSearch = update.hasSubmittedSearch, - isSuggestionVisible = current.isSuggestionVisible && update.suggestionsEnabled, + availableTabs = enabledSources.toSearchTabTypes(), + selectedTab = resolvedSelectedSource?.toSearchTabTypeOrNull(), + hasSubmittedSearch = updatedHasSubmittedSearch, + suggestions = updatedSuggestions, + isSuggestionVisible = current.isSuggestionVisible && areSuggestionsEnabled, ) } - update.searchRequest?.let { request -> + searchRequest?.let { request -> searchRequests.value = request } } diff --git a/app/src/test/java/foundation/e/apps/domain/search/UpdateSearchForStoreSelectionUseCaseTest.kt b/app/src/test/java/foundation/e/apps/domain/search/UpdateSearchForStoreSelectionUseCaseTest.kt deleted file mode 100644 index fdb68dfb7..000000000 --- a/app/src/test/java/foundation/e/apps/domain/search/UpdateSearchForStoreSelectionUseCaseTest.kt +++ /dev/null @@ -1,106 +0,0 @@ -/* - * 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.domain.search - -import com.google.common.truth.Truth.assertThat -import foundation.e.apps.data.Stores -import foundation.e.apps.data.enums.Source -import foundation.e.apps.data.preference.AppLoungePreference -import io.mockk.every -import io.mockk.mockk -import org.junit.Before -import org.junit.Test - -class UpdateSearchForStoreSelectionUseCaseTest { - - private lateinit var stores: Stores - private lateinit var appLoungePreference: AppLoungePreference - private lateinit var useCase: UpdateSearchForStoreSelectionUseCase - - @Before - fun setUp() { - stores = mockk() - appLoungePreference = mockk() - useCase = UpdateSearchForStoreSelectionUseCase( - stores, - appLoungePreference, - ) - } - - @Test - fun `submitted search updates request when enabled sources are available`() { - every { stores.getEnabledSearchSources() } returns listOf( - Source.PLAY_STORE, - Source.PWA, - ) - every { appLoungePreference.isPlayStoreSelected() } returns true - - val result = useCase( - currentQuery = "apps", - selectedSource = Source.PWA, - hasSubmittedSearch = true, - currentVersion = 4, - ) - - assertThat(result.searchRequest).isNotNull() - assertThat(result.searchRequest?.query).isEqualTo("apps") - assertThat(result.searchRequest?.version).isEqualTo(4) - assertThat(result.selectedSource).isEqualTo(Source.PWA) - assertThat(result.hasSubmittedSearch).isTrue() - assertThat(result.suggestionsEnabled).isTrue() - } - - @Test - fun `no submitted search emits empty query request`() { - every { stores.getEnabledSearchSources() } returns listOf(Source.OPEN_SOURCE) - every { appLoungePreference.isPlayStoreSelected() } returns false - - val result = useCase( - currentQuery = "", - selectedSource = Source.OPEN_SOURCE, - hasSubmittedSearch = false, - currentVersion = 2, - ) - - assertThat(result.searchRequest).isNotNull() - assertThat(result.searchRequest?.query).isEqualTo("") - assertThat(result.searchRequest?.version).isEqualTo(2) - assertThat(result.hasSubmittedSearch).isFalse() - assertThat(result.suggestionsEnabled).isFalse() - assertThat(result.selectedSource).isEqualTo(Source.OPEN_SOURCE) - } - - @Test - fun `empty enabled sources clear selection and skip request update`() { - every { stores.getEnabledSearchSources() } returns emptyList() - every { appLoungePreference.isPlayStoreSelected() } returns true - - val result = useCase( - currentQuery = "apps", - selectedSource = Source.PLAY_STORE, - hasSubmittedSearch = true, - currentVersion = 1, - ) - - assertThat(result.enabledSources).isEmpty() - assertThat(result.selectedSource).isNull() - assertThat(result.hasSubmittedSearch).isFalse() - assertThat(result.searchRequest).isNull() - } -} diff --git a/app/src/test/java/foundation/e/apps/ui/search/v2/SearchViewModelV2Test.kt b/app/src/test/java/foundation/e/apps/ui/search/v2/SearchViewModelV2Test.kt index 6cfd30c13..37e54d24c 100644 --- a/app/src/test/java/foundation/e/apps/ui/search/v2/SearchViewModelV2Test.kt +++ b/app/src/test/java/foundation/e/apps/ui/search/v2/SearchViewModelV2Test.kt @@ -40,7 +40,6 @@ import foundation.e.apps.domain.search.FetchSearchSuggestionsUseCase import foundation.e.apps.domain.search.PlayStoreAppMapper import foundation.e.apps.domain.search.PlayStoreSearchPagingUseCase import foundation.e.apps.domain.search.PrepareSearchSubmissionUseCase -import foundation.e.apps.domain.search.UpdateSearchForStoreSelectionUseCase import foundation.e.apps.ui.applicationlist.ApplicationDiffUtil import foundation.e.apps.ui.compose.state.InstallStatusReconciler import foundation.e.apps.ui.compose.state.InstallStatusStream @@ -80,7 +79,6 @@ class SearchViewModelV2Test { private lateinit var playStoreSearchPagingUseCase: PlayStoreSearchPagingUseCase private lateinit var fetchSearchSuggestionsUseCase: FetchSearchSuggestionsUseCase private lateinit var prepareSearchSubmissionUseCase: PrepareSearchSubmissionUseCase - private lateinit var updateSearchForStoreSelectionUseCase: UpdateSearchForStoreSelectionUseCase private lateinit var stores: Stores private lateinit var installStatusStream: InstallStatusStream private lateinit var installStatusReconciler: InstallStatusReconciler @@ -320,6 +318,25 @@ class SearchViewModelV2Test { assertFalse(state.isSuggestionVisible) } + @Test + fun `store change clears suggestions when play store turns off`() = runTest { + playStoreSelected = true + buildViewModel() + viewModel.onQueryChanged("tel") + advanceDebounce() + + val stateWithSuggestions = viewModel.uiState.value + assertTrue(stateWithSuggestions.isSuggestionVisible) + assertFalse(stateWithSuggestions.suggestions.isEmpty()) + + stores.disableStore(Source.PLAY_STORE) + runStoreUpdates() + + val state = viewModel.uiState.value + assertFalse(state.isSuggestionVisible) + assertTrue(state.suggestions.isEmpty()) + } + @Test fun `store change removing all tabs clears submitted state`() = runTest { playStoreSelected = true @@ -621,17 +638,12 @@ class SearchViewModelV2Test { stores = buildStores() fetchSearchSuggestionsUseCase = FetchSearchSuggestionsUseCase(suggestionSource, preference) prepareSearchSubmissionUseCase = PrepareSearchSubmissionUseCase(stores) - updateSearchForStoreSelectionUseCase = UpdateSearchForStoreSelectionUseCase( - stores, - preference, - ) viewModel = SearchViewModelV2( preference, cleanApkSearchPagingUseCase, playStoreSearchPagingUseCase, fetchSearchSuggestionsUseCase, prepareSearchSubmissionUseCase, - updateSearchForStoreSelectionUseCase, stores, installStatusStream, installStatusReconciler, -- GitLab From 0e1892ee23dbb20cb07cff85b54bc3b4fa594da8 Mon Sep 17 00:00:00 2001 From: Fahim Masud Choudhury Date: Tue, 3 Feb 2026 15:57:49 +0600 Subject: [PATCH 11/17] refactor: improve install button state mapping to reduce complexity Introduce a dedicated input model and split mapping logic into focused helpers, updating call sites and tests to improve readability. --- .../compose/state/InstallButtonStateInput.kt | 55 ++++ .../compose/state/InstallButtonStateMapper.kt | 310 +++++++++--------- .../e/apps/ui/search/v2/SearchFragmentV2.kt | 21 +- .../state/InstallButtonStateMapperTest.kt | 267 +++++++-------- 4 files changed, 363 insertions(+), 290 deletions(-) create mode 100644 app/src/main/java/foundation/e/apps/ui/compose/state/InstallButtonStateInput.kt diff --git a/app/src/main/java/foundation/e/apps/ui/compose/state/InstallButtonStateInput.kt b/app/src/main/java/foundation/e/apps/ui/compose/state/InstallButtonStateInput.kt new file mode 100644 index 000000000..9321c0137 --- /dev/null +++ b/app/src/main/java/foundation/e/apps/ui/compose/state/InstallButtonStateInput.kt @@ -0,0 +1,55 @@ +/* + * 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.ui.compose.state + +import foundation.e.apps.data.application.data.Application +import foundation.e.apps.data.enums.Status +import foundation.e.apps.data.enums.User +import foundation.e.apps.install.pkg.InstallerService + +data class InstallationFault( + val isFaulty: Boolean, + val reason: String, +) + +data class InstallButtonStateInput( + val app: Application, + val user: User, + val isAnonymousUser: Boolean, + val isUnsupported: Boolean, + val installationFault: InstallationFault?, + val purchaseState: PurchaseState, + val progressPercent: Int?, + val isSelfUpdate: Boolean, + val overrideStatus: Status? = null, +) { + val resolvedStatus: Status + get() = overrideStatus ?: app.status + + val percentLabel: String? + get() = progressPercent?.takeIf { it in 0..PERCENTAGE_MAX }?.let { "$it%" } + + val isFaulty: Boolean + get() = installationFault?.isFaulty ?: false + + val isUpdateIncompatible: Boolean + get() = installationFault?.reason == InstallerService.INSTALL_FAILED_UPDATE_INCOMPATIBLE +} + +private const val PERCENTAGE_MAX = 100 diff --git a/app/src/main/java/foundation/e/apps/ui/compose/state/InstallButtonStateMapper.kt b/app/src/main/java/foundation/e/apps/ui/compose/state/InstallButtonStateMapper.kt index 19eac8d70..b24d7f013 100644 --- a/app/src/main/java/foundation/e/apps/ui/compose/state/InstallButtonStateMapper.kt +++ b/app/src/main/java/foundation/e/apps/ui/compose/state/InstallButtonStateMapper.kt @@ -22,176 +22,184 @@ import foundation.e.apps.R import foundation.e.apps.data.application.data.Application import foundation.e.apps.data.enums.Status import foundation.e.apps.data.enums.User -import foundation.e.apps.install.pkg.InstallerService /* * Map raw application + contextual signals into a single button state. * Keep pure: no side effects; callers handle actions. */ -@Suppress("CyclomaticComplexMethod", "LongMethod", "LongParameterList") -fun mapAppToInstallState( - app: Application, - user: User, - isAnonymousUser: Boolean, - isUnsupported: Boolean, - faultyResult: Pair?, - purchaseState: PurchaseState, - progressPercent: Int?, - isSelfUpdate: Boolean, - overrideStatus: Status? = null, -): InstallButtonState { - val status = overrideStatus ?: app.status - val percentLabel = progressPercent?.takeIf { it in 0..PERCENTAGE_MAX }?.let { "$it%" } - - return when (status) { - Status.INSTALLED -> InstallButtonState( - label = ButtonLabel(resId = R.string.open), - enabled = true, - style = buildStyleFor(status = Status.INSTALLED, enabled = true), - actionIntent = InstallButtonAction.OpenAppOrPwa, - statusTag = StatusTag.Installed, - ) +fun mapAppToInstallState(input: InstallButtonStateInput): InstallButtonState { + return when (val status = input.resolvedStatus) { + Status.INSTALLED -> mapInstalled() + Status.UPDATABLE -> mapUpdatable(input) + Status.UNAVAILABLE -> mapUnavailable(input) + Status.QUEUED, Status.AWAITING, Status.DOWNLOADING, Status.DOWNLOADED -> mapDownloading(input, status) + Status.INSTALLING -> mapInstalling(status) + Status.BLOCKED -> mapBlocked(input) + Status.INSTALLATION_ISSUE -> mapInstallationIssue(input) + else -> mapUnknown(input) + } +} - Status.UPDATABLE -> { - val unsupported = isUnsupported - InstallButtonState( - label = ButtonLabel(resId = if (unsupported) R.string.not_available else R.string.update), - enabled = true, - style = buildStyleFor(status = Status.UPDATABLE, enabled = true), - actionIntent = if (unsupported) { - InstallButtonAction.NoOp - } else if (isSelfUpdate) { - InstallButtonAction.UpdateSelfConfirm - } else { - InstallButtonAction.Install - }, - dialogType = if (!unsupported && isSelfUpdate) InstallDialogType.SelfUpdateConfirmation else null, - statusTag = StatusTag.Updatable, - ) - } +private fun mapInstalled(): InstallButtonState { + return InstallButtonState( + label = ButtonLabel(resId = R.string.open), + enabled = true, + style = buildStyleFor(status = Status.INSTALLED, enabled = true), + actionIntent = InstallButtonAction.OpenAppOrPwa, + statusTag = StatusTag.Installed, + ) +} - Status.UNAVAILABLE -> { - when { - isUnsupported -> InstallButtonState( - label = ButtonLabel(resId = R.string.not_available), - enabled = true, - style = buildStyleFor(Status.UNAVAILABLE, enabled = true), - actionIntent = InstallButtonAction.NoOp, - statusTag = StatusTag.UnavailableUnsupported, - ) - - app.isFree -> InstallButtonState( - label = ButtonLabel(resId = R.string.install), - enabled = true, - style = buildStyleFor(Status.UNAVAILABLE, enabled = true), - actionIntent = InstallButtonAction.Install, - statusTag = StatusTag.UnavailableFree, - ) - - isAnonymousUser -> InstallButtonState( - label = ButtonLabel(text = app.price), - enabled = true, - style = buildStyleFor(Status.UNAVAILABLE, enabled = true), - actionIntent = InstallButtonAction.ShowPaidDialog, - dialogType = InstallDialogType.PaidAppDialog, - statusTag = StatusTag.UnavailablePaid, - ) - - else -> { - when (purchaseState) { - PurchaseState.Loading, PurchaseState.Unknown -> InstallButtonState( - label = ButtonLabel(text = ""), - enabled = false, - style = buildStyleFor(Status.UNAVAILABLE, enabled = false), - showProgressBar = true, - actionIntent = InstallButtonAction.NoOp, - statusTag = StatusTag.UnavailablePaid, - ) - - PurchaseState.Purchased -> InstallButtonState( - label = ButtonLabel(resId = R.string.install), - enabled = true, - style = buildStyleFor(Status.UNAVAILABLE, enabled = true), - actionIntent = InstallButtonAction.Install, - statusTag = StatusTag.UnavailablePaid, - ) - - PurchaseState.NotPurchased -> InstallButtonState( - label = ButtonLabel(text = app.price), - enabled = true, - style = buildStyleFor(Status.UNAVAILABLE, enabled = true), - actionIntent = InstallButtonAction.ShowPaidDialog, - dialogType = InstallDialogType.PaidAppDialog, - statusTag = StatusTag.UnavailablePaid, - ) - } - } - } - } +private fun mapUpdatable(input: InstallButtonStateInput): InstallButtonState { + val unsupported = input.isUnsupported + return InstallButtonState( + label = ButtonLabel(resId = if (unsupported) R.string.not_available else R.string.update), + enabled = true, + style = buildStyleFor(status = Status.UPDATABLE, enabled = true), + actionIntent = when { + unsupported -> InstallButtonAction.NoOp + input.isSelfUpdate -> InstallButtonAction.UpdateSelfConfirm + else -> InstallButtonAction.Install + }, + dialogType = if (!unsupported && input.isSelfUpdate) InstallDialogType.SelfUpdateConfirmation else null, + statusTag = StatusTag.Updatable, + ) +} - Status.QUEUED, Status.AWAITING, Status.DOWNLOADING, Status.DOWNLOADED -> InstallButtonState( - label = ButtonLabel( - resId = if (percentLabel == null) R.string.cancel else null, - text = percentLabel, - ), - progressPercentText = percentLabel, - enabled = true, - style = buildStyleFor(status, enabled = true), - actionIntent = InstallButtonAction.CancelDownload, - statusTag = StatusTag.Downloading, - rawStatus = status, - ) +private fun mapUnavailable(input: InstallButtonStateInput): InstallButtonState { + return when { + input.isUnsupported -> mapUnavailableUnsupported() + input.app.isFree -> mapUnavailableFree() + input.isAnonymousUser -> mapUnavailableAnonymous(input.app) + else -> mapUnavailablePaid(input) + } +} + +private fun mapUnavailableUnsupported(): InstallButtonState { + return InstallButtonState( + label = ButtonLabel(resId = R.string.not_available), + enabled = true, + style = buildStyleFor(Status.UNAVAILABLE, enabled = true), + actionIntent = InstallButtonAction.NoOp, + statusTag = StatusTag.UnavailableUnsupported, + ) +} + +private fun mapUnavailableFree(): InstallButtonState { + return InstallButtonState( + label = ButtonLabel(resId = R.string.install), + enabled = true, + style = buildStyleFor(Status.UNAVAILABLE, enabled = true), + actionIntent = InstallButtonAction.Install, + statusTag = StatusTag.UnavailableFree, + ) +} + +private fun mapUnavailableAnonymous(app: Application): InstallButtonState { + return InstallButtonState( + label = ButtonLabel(text = app.price), + enabled = true, + style = buildStyleFor(Status.UNAVAILABLE, enabled = true), + actionIntent = InstallButtonAction.ShowPaidDialog, + dialogType = InstallDialogType.PaidAppDialog, + statusTag = StatusTag.UnavailablePaid, + ) +} - Status.INSTALLING -> InstallButtonState( - label = ButtonLabel(resId = R.string.installing), +private fun mapUnavailablePaid(input: InstallButtonStateInput): InstallButtonState { + return when (input.purchaseState) { + PurchaseState.Loading, PurchaseState.Unknown -> InstallButtonState( + label = ButtonLabel(text = ""), enabled = false, - style = buildStyleFor(status, enabled = false), + style = buildStyleFor(Status.UNAVAILABLE, enabled = false), + showProgressBar = true, actionIntent = InstallButtonAction.NoOp, - statusTag = StatusTag.Installing, - rawStatus = status, + statusTag = StatusTag.UnavailablePaid, ) - Status.BLOCKED -> { - val messageId = when (user) { - User.ANONYMOUS, User.NO_GOOGLE -> R.string.install_blocked_anonymous - User.GOOGLE -> R.string.install_blocked_google - } - InstallButtonState( - label = buildDefaultBlockedLabel(app), - enabled = true, - style = buildStyleFor(Status.BLOCKED, enabled = true), - actionIntent = InstallButtonAction.ShowBlockedSnackbar, - snackbarMessageId = messageId, - statusTag = StatusTag.Blocked, - rawStatus = app.status, - ) - } - - Status.INSTALLATION_ISSUE -> { - val faulty = faultyResult ?: (false to "") - val incompatible = faulty.second == InstallerService.INSTALL_FAILED_UPDATE_INCOMPATIBLE - InstallButtonState( - label = ButtonLabel(resId = if (incompatible) R.string.update else R.string.retry), - enabled = !faulty.first, - style = buildStyleFor(Status.INSTALLATION_ISSUE, enabled = !faulty.first), - actionIntent = InstallButtonAction.Install, - statusTag = StatusTag.InstallationIssue, - rawStatus = app.status, - ) - } - - else -> InstallButtonState( + PurchaseState.Purchased -> InstallButtonState( label = ButtonLabel(resId = R.string.install), enabled = true, - style = InstallButtonStyle.AccentOutline, - actionIntent = InstallButtonAction.NoOp, - statusTag = StatusTag.Unknown, - rawStatus = app.status, + style = buildStyleFor(Status.UNAVAILABLE, enabled = true), + actionIntent = InstallButtonAction.Install, + statusTag = StatusTag.UnavailablePaid, ) + + PurchaseState.NotPurchased -> InstallButtonState( + label = ButtonLabel(text = input.app.price), + enabled = true, + style = buildStyleFor(Status.UNAVAILABLE, enabled = true), + actionIntent = InstallButtonAction.ShowPaidDialog, + dialogType = InstallDialogType.PaidAppDialog, + statusTag = StatusTag.UnavailablePaid, + ) + } +} + +private fun mapDownloading(input: InstallButtonStateInput, status: Status): InstallButtonState { + return InstallButtonState( + label = ButtonLabel( + resId = if (input.percentLabel == null) R.string.cancel else null, + text = input.percentLabel, + ), + progressPercentText = input.percentLabel, + enabled = true, + style = buildStyleFor(status, enabled = true), + actionIntent = InstallButtonAction.CancelDownload, + statusTag = StatusTag.Downloading, + rawStatus = status, + ) +} + +private fun mapInstalling(status: Status): InstallButtonState { + return InstallButtonState( + label = ButtonLabel(resId = R.string.installing), + enabled = false, + style = buildStyleFor(status, enabled = false), + actionIntent = InstallButtonAction.NoOp, + statusTag = StatusTag.Installing, + rawStatus = status, + ) +} + +private fun mapBlocked(input: InstallButtonStateInput): InstallButtonState { + val messageId = when (input.user) { + User.ANONYMOUS, User.NO_GOOGLE -> R.string.install_blocked_anonymous + User.GOOGLE -> R.string.install_blocked_google } + return InstallButtonState( + label = buildDefaultBlockedLabel(input.app), + enabled = true, + style = buildStyleFor(Status.BLOCKED, enabled = true), + actionIntent = InstallButtonAction.ShowBlockedSnackbar, + snackbarMessageId = messageId, + statusTag = StatusTag.Blocked, + rawStatus = input.app.status, + ) +} + +private fun mapInstallationIssue(input: InstallButtonStateInput): InstallButtonState { + val enabled = !input.isFaulty + return InstallButtonState( + label = ButtonLabel(resId = if (input.isUpdateIncompatible) R.string.update else R.string.retry), + enabled = enabled, + style = buildStyleFor(Status.INSTALLATION_ISSUE, enabled = enabled), + actionIntent = InstallButtonAction.Install, + statusTag = StatusTag.InstallationIssue, + rawStatus = input.app.status, + ) } -private const val PERCENTAGE_MAX = 100 +private fun mapUnknown(input: InstallButtonStateInput): InstallButtonState { + return InstallButtonState( + label = ButtonLabel(resId = R.string.install), + enabled = true, + style = InstallButtonStyle.AccentOutline, + actionIntent = InstallButtonAction.NoOp, + statusTag = StatusTag.Unknown, + rawStatus = input.app.status, + ) +} private fun buildDefaultBlockedLabel(app: Application): ButtonLabel { val literal = app.price.takeIf { it.isNotBlank() } diff --git a/app/src/main/java/foundation/e/apps/ui/search/v2/SearchFragmentV2.kt b/app/src/main/java/foundation/e/apps/ui/search/v2/SearchFragmentV2.kt index 7abb256fe..264941bad 100644 --- a/app/src/main/java/foundation/e/apps/ui/search/v2/SearchFragmentV2.kt +++ b/app/src/main/java/foundation/e/apps/ui/search/v2/SearchFragmentV2.kt @@ -49,6 +49,7 @@ import foundation.e.apps.ui.application.subFrags.ApplicationDialogFragment import foundation.e.apps.ui.compose.screens.SearchScreen import foundation.e.apps.ui.compose.state.InstallButtonAction import foundation.e.apps.ui.compose.state.InstallButtonState +import foundation.e.apps.ui.compose.state.InstallButtonStateInput import foundation.e.apps.ui.compose.state.PurchaseState import foundation.e.apps.ui.compose.state.mapAppToInstallState import foundation.e.apps.ui.compose.theme.AppTheme @@ -112,15 +113,17 @@ class SearchFragmentV2 : Fragment(R.layout.fragment_search_v2) { else -> app } mapAppToInstallState( - app = effectiveApp, - user = user, - isAnonymousUser = isAnonymous, - isUnsupported = isUnsupported, - faultyResult = null, - purchaseState = purchaseState, - progressPercent = progressPercent, - isSelfUpdate = app.package_name == requireContext().packageName, - overrideStatus = overrideStatus, + InstallButtonStateInput( + app = effectiveApp, + user = user, + isAnonymousUser = isAnonymous, + isUnsupported = isUnsupported, + installationFault = null, + purchaseState = purchaseState, + progressPercent = progressPercent, + isSelfUpdate = app.package_name == requireContext().packageName, + overrideStatus = overrideStatus, + ) ) .also { // Restore original status to avoid mutating shared paging instance. diff --git a/app/src/test/java/foundation/e/apps/ui/compose/state/InstallButtonStateMapperTest.kt b/app/src/test/java/foundation/e/apps/ui/compose/state/InstallButtonStateMapperTest.kt index 15e6d450a..f81b483da 100644 --- a/app/src/test/java/foundation/e/apps/ui/compose/state/InstallButtonStateMapperTest.kt +++ b/app/src/test/java/foundation/e/apps/ui/compose/state/InstallButtonStateMapperTest.kt @@ -31,6 +31,36 @@ import org.junit.Test class InstallButtonStateMapperTest { + private companion object { + private const val PAID_PRICE = "\$1.99" + private const val PROGRESS_PERCENT_VALID = 42 + private const val PROGRESS_PERCENT_BELOW_MIN = -1 + private const val PROGRESS_PERCENT_ABOVE_MAX = 120 + private const val PROGRESS_PERCENT_OVERRIDE = 10 + } + + private fun defaultInput( + app: Application = baseApp(Status.INSTALLED), + user: User = User.GOOGLE, + isAnonymousUser: Boolean = false, + isUnsupported: Boolean = false, + installationFault: InstallationFault? = null, + purchaseState: PurchaseState = PurchaseState.Unknown, + progressPercent: Int? = null, + isSelfUpdate: Boolean = false, + overrideStatus: Status? = null, + ) = InstallButtonStateInput( + app = app, + user = user, + isAnonymousUser = isAnonymousUser, + isUnsupported = isUnsupported, + installationFault = installationFault, + purchaseState = purchaseState, + progressPercent = progressPercent, + isSelfUpdate = isSelfUpdate, + overrideStatus = overrideStatus, + ) + private fun baseApp( status: Status, isFree: Boolean = true, @@ -50,14 +80,7 @@ class InstallButtonStateMapperTest { @Test fun installed_maps_to_open() { val state = mapAppToInstallState( - app = baseApp(Status.INSTALLED), - user = User.GOOGLE, - isAnonymousUser = false, - isUnsupported = false, - faultyResult = null, - purchaseState = PurchaseState.Unknown, - progressPercent = null, - isSelfUpdate = false, + input = defaultInput(app = baseApp(Status.INSTALLED)), ) assertEquals(R.string.open, state.label.resId) assertEquals(InstallButtonAction.OpenAppOrPwa, state.actionIntent) @@ -69,14 +92,10 @@ class InstallButtonStateMapperTest { @Test fun updatable_self_update_sets_dialog_and_intent() { val state = mapAppToInstallState( - app = baseApp(Status.UPDATABLE), - user = User.GOOGLE, - isAnonymousUser = false, - isUnsupported = false, - faultyResult = null, - purchaseState = PurchaseState.Unknown, - progressPercent = null, - isSelfUpdate = true, + input = defaultInput( + app = baseApp(Status.UPDATABLE), + isSelfUpdate = true, + ), ) assertEquals(R.string.update, state.label.resId) assertEquals(InstallButtonAction.UpdateSelfConfirm, state.actionIntent) @@ -88,14 +107,10 @@ class InstallButtonStateMapperTest { @Test fun updatable_unsupported_is_noop() { val state = mapAppToInstallState( - app = baseApp(Status.UPDATABLE), - user = User.GOOGLE, - isAnonymousUser = false, - isUnsupported = true, - faultyResult = null, - purchaseState = PurchaseState.Unknown, - progressPercent = null, - isSelfUpdate = false, + input = defaultInput( + app = baseApp(Status.UPDATABLE), + isUnsupported = true, + ), ) assertEquals(R.string.not_available, state.label.resId) assertEquals(InstallButtonAction.NoOp, state.actionIntent) @@ -105,14 +120,7 @@ class InstallButtonStateMapperTest { @Test fun unavailable_free_installs() { val state = mapAppToInstallState( - app = baseApp(Status.UNAVAILABLE, isFree = true), - user = User.GOOGLE, - isAnonymousUser = false, - isUnsupported = false, - faultyResult = null, - purchaseState = PurchaseState.Unknown, - progressPercent = null, - isSelfUpdate = false, + input = defaultInput(app = baseApp(Status.UNAVAILABLE, isFree = true)), ) assertEquals(R.string.install, state.label.resId) assertEquals(InstallButtonAction.Install, state.actionIntent) @@ -122,16 +130,14 @@ class InstallButtonStateMapperTest { @Test fun unavailable_paid_anonymous_shows_price_and_paid_dialog() { val state = mapAppToInstallState( - app = baseApp(Status.UNAVAILABLE, isFree = false, price = "$1.99"), - user = User.ANONYMOUS, - isAnonymousUser = true, - isUnsupported = false, - faultyResult = null, - purchaseState = PurchaseState.NotPurchased, - progressPercent = null, - isSelfUpdate = false, + input = defaultInput( + app = baseApp(Status.UNAVAILABLE, isFree = false, price = PAID_PRICE), + user = User.ANONYMOUS, + isAnonymousUser = true, + purchaseState = PurchaseState.NotPurchased, + ), ) - assertEquals("$1.99", state.label.text) + assertEquals(PAID_PRICE, state.label.text) assertEquals(InstallButtonAction.ShowPaidDialog, state.actionIntent) assertEquals(InstallDialogType.PaidAppDialog, state.dialogType) assertEquals(StatusTag.UnavailablePaid, state.statusTag) @@ -140,14 +146,10 @@ class InstallButtonStateMapperTest { @Test fun unavailable_paid_loading_disables_with_progress() { val state = mapAppToInstallState( - app = baseApp(Status.UNAVAILABLE, isFree = false, price = "$1.99"), - user = User.GOOGLE, - isAnonymousUser = false, - isUnsupported = false, - faultyResult = null, - purchaseState = PurchaseState.Loading, - progressPercent = null, - isSelfUpdate = false, + input = defaultInput( + app = baseApp(Status.UNAVAILABLE, isFree = false, price = PAID_PRICE), + purchaseState = PurchaseState.Loading, + ), ) assertFalse(state.enabled) assertTrue(state.showProgressBar) @@ -157,14 +159,10 @@ class InstallButtonStateMapperTest { @Test fun unavailable_paid_purchased_installs() { val state = mapAppToInstallState( - app = baseApp(Status.UNAVAILABLE, isFree = false, price = "$1.99"), - user = User.GOOGLE, - isAnonymousUser = false, - isUnsupported = false, - faultyResult = null, - purchaseState = PurchaseState.Purchased, - progressPercent = null, - isSelfUpdate = false, + input = defaultInput( + app = baseApp(Status.UNAVAILABLE, isFree = false, price = PAID_PRICE), + purchaseState = PurchaseState.Purchased, + ), ) assertEquals(R.string.install, state.label.resId) assertEquals(InstallButtonAction.Install, state.actionIntent) @@ -174,14 +172,10 @@ class InstallButtonStateMapperTest { @Test fun unavailable_unsupported_noop() { val state = mapAppToInstallState( - app = baseApp(Status.UNAVAILABLE, isFree = false, price = "$1.99"), - user = User.GOOGLE, - isAnonymousUser = false, - isUnsupported = true, - faultyResult = null, - purchaseState = PurchaseState.Unknown, - progressPercent = null, - isSelfUpdate = false, + input = defaultInput( + app = baseApp(Status.UNAVAILABLE, isFree = false, price = PAID_PRICE), + isUnsupported = true, + ), ) assertEquals(R.string.not_available, state.label.resId) assertEquals(InstallButtonAction.NoOp, state.actionIntent) @@ -191,16 +185,12 @@ class InstallButtonStateMapperTest { @Test fun downloading_with_progress_shows_percent_and_cancel_intent() { val state = mapAppToInstallState( - app = baseApp(Status.DOWNLOADING), - user = User.GOOGLE, - isAnonymousUser = false, - isUnsupported = false, - faultyResult = null, - purchaseState = PurchaseState.Unknown, - progressPercent = 42, - isSelfUpdate = false, + input = defaultInput( + app = baseApp(Status.DOWNLOADING), + progressPercent = PROGRESS_PERCENT_VALID, + ), ) - assertEquals("42%", state.label.text) + assertEquals("${PROGRESS_PERCENT_VALID}%", state.label.text) assertEquals(InstallButtonAction.CancelDownload, state.actionIntent) assertEquals(StatusTag.Downloading, state.statusTag) } @@ -208,30 +198,40 @@ class InstallButtonStateMapperTest { @Test fun downloading_without_progress_shows_cancel_label() { val state = mapAppToInstallState( - app = baseApp(Status.DOWNLOADING), - user = User.GOOGLE, - isAnonymousUser = false, - isUnsupported = false, - faultyResult = null, - purchaseState = PurchaseState.Unknown, - progressPercent = null, - isSelfUpdate = false, + input = defaultInput(app = baseApp(Status.DOWNLOADING)), ) assertEquals(R.string.cancel, state.label.resId) assertEquals(InstallButtonAction.CancelDownload, state.actionIntent) } + @Test + fun downloading_progress_below_zero_uses_cancel_label() { + val state = mapAppToInstallState( + input = defaultInput( + app = baseApp(Status.DOWNLOADING), + progressPercent = PROGRESS_PERCENT_BELOW_MIN, + ), + ) + assertEquals(R.string.cancel, state.label.resId) + assertEquals(null, state.label.text) + } + + @Test + fun downloading_progress_above_max_uses_cancel_label() { + val state = mapAppToInstallState( + input = defaultInput( + app = baseApp(Status.DOWNLOADING), + progressPercent = PROGRESS_PERCENT_ABOVE_MAX, + ), + ) + assertEquals(R.string.cancel, state.label.resId) + assertEquals(null, state.label.text) + } + @Test fun installing_disabled() { val state = mapAppToInstallState( - app = baseApp(Status.INSTALLING), - user = User.GOOGLE, - isAnonymousUser = false, - isUnsupported = false, - faultyResult = null, - purchaseState = PurchaseState.Unknown, - progressPercent = null, - isSelfUpdate = false, + input = defaultInput(app = baseApp(Status.INSTALLING)), ) assertEquals(R.string.installing, state.label.resId) assertFalse(state.enabled) @@ -241,27 +241,21 @@ class InstallButtonStateMapperTest { @Test fun blocked_snackbar_differs_by_user() { val stateAnon = mapAppToInstallState( - app = baseApp(Status.BLOCKED), - user = User.ANONYMOUS, - isAnonymousUser = true, - isUnsupported = false, - faultyResult = null, - purchaseState = PurchaseState.Unknown, - progressPercent = null, - isSelfUpdate = false, + input = defaultInput( + app = baseApp(Status.BLOCKED), + user = User.ANONYMOUS, + isAnonymousUser = true, + ), ) assertEquals(R.string.install_blocked_anonymous, stateAnon.snackbarMessageId) assertEquals(InstallButtonAction.ShowBlockedSnackbar, stateAnon.actionIntent) val stateGoogle = mapAppToInstallState( - app = baseApp(Status.BLOCKED), - user = User.GOOGLE, - isAnonymousUser = false, - isUnsupported = false, - faultyResult = null, - purchaseState = PurchaseState.Unknown, - progressPercent = null, - isSelfUpdate = false, + input = defaultInput( + app = baseApp(Status.BLOCKED), + user = User.GOOGLE, + isAnonymousUser = false, + ), ) assertEquals(R.string.install_blocked_google, stateGoogle.snackbarMessageId) } @@ -269,43 +263,56 @@ class InstallButtonStateMapperTest { @Test fun installation_issue_faulty_disables_and_uses_retry_or_update() { val faultyState = mapAppToInstallState( - app = baseApp(Status.INSTALLATION_ISSUE), - user = User.GOOGLE, - isAnonymousUser = false, - isUnsupported = false, - faultyResult = true to "ERROR", - purchaseState = PurchaseState.Unknown, - progressPercent = null, - isSelfUpdate = false, + input = defaultInput( + app = baseApp(Status.INSTALLATION_ISSUE), + installationFault = InstallationFault(isFaulty = true, reason = "ERROR"), + ), ) assertFalse(faultyState.enabled) assertEquals(R.string.retry, faultyState.label.resId) val incompatibleState = mapAppToInstallState( - app = baseApp(Status.INSTALLATION_ISSUE), - user = User.GOOGLE, - isAnonymousUser = false, - isUnsupported = false, - faultyResult = true to InstallerService.INSTALL_FAILED_UPDATE_INCOMPATIBLE, - purchaseState = PurchaseState.Unknown, - progressPercent = null, - isSelfUpdate = false, + input = defaultInput( + app = baseApp(Status.INSTALLATION_ISSUE), + installationFault = InstallationFault( + isFaulty = true, + reason = InstallerService.INSTALL_FAILED_UPDATE_INCOMPATIBLE, + ), + ), ) assertEquals(R.string.update, incompatibleState.label.resId) } + @Test + fun override_status_uses_resolved_status_for_downloading_raw_status() { + val state = mapAppToInstallState( + input = defaultInput( + app = baseApp(Status.INSTALLED), + overrideStatus = Status.DOWNLOADING, + progressPercent = PROGRESS_PERCENT_OVERRIDE, + ), + ) + assertEquals(Status.DOWNLOADING, state.rawStatus) + assertEquals(StatusTag.Downloading, state.statusTag) + } + + @Test + fun override_status_preserves_app_status_for_non_download_states() { + val state = mapAppToInstallState( + input = defaultInput( + app = baseApp(Status.INSTALLED), + overrideStatus = Status.BLOCKED, + ), + ) + assertEquals(Status.INSTALLED, state.rawStatus) + assertEquals(StatusTag.Blocked, state.statusTag) + } + @Test fun purchase_needed_status_defaults_to_noop() { val app = baseApp(Status.PURCHASE_NEEDED) val state = mapAppToInstallState( - app = app, - user = User.GOOGLE, - isAnonymousUser = false, - isUnsupported = false, - faultyResult = null, - purchaseState = PurchaseState.Unknown, - progressPercent = null, - isSelfUpdate = false, + input = defaultInput(app = app), ) assertEquals(InstallButtonAction.NoOp, state.actionIntent) assertEquals(StatusTag.Unknown, state.statusTag) -- GitLab From 3e5dfe70b766476bf7126a1fcaba3e7e1a050ae6 Mon Sep 17 00:00:00 2001 From: Fahim Masud Choudhury Date: Tue, 3 Feb 2026 16:27:32 +0600 Subject: [PATCH 12/17] refactor: improve method readability in AppManagerWrapper --- .../e/apps/data/install/AppManagerWrapper.kt | 37 ++++++++++--------- 1 file changed, 20 insertions(+), 17 deletions(-) diff --git a/app/src/main/java/foundation/e/apps/data/install/AppManagerWrapper.kt b/app/src/main/java/foundation/e/apps/data/install/AppManagerWrapper.kt index 77fd2c9e1..4fce1f968 100644 --- a/app/src/main/java/foundation/e/apps/data/install/AppManagerWrapper.kt +++ b/app/src/main/java/foundation/e/apps/data/install/AppManagerWrapper.kt @@ -143,23 +143,26 @@ class AppManagerWrapper @Inject constructor( progress: DownloadProgress ): Int { val downloadIds = appInstall.downloadIdMap.keys - var percent = 0 - - if (downloadIds.isNotEmpty()) { - val totalSizeBytes = progress.totalSizeBytes - .filterKeys { downloadIds.contains(it) } - .values - .sum() - - if (totalSizeBytes > 0) { - val downloadedSoFar = progress.bytesDownloadedSoFar - .filterKeys { downloadIds.contains(it) } - .values - .sum() - percent = ((downloadedSoFar / totalSizeBytes.toDouble()) * PERCENTAGE_MULTIPLIER) - .toInt() - .coerceIn(0, PERCENTAGE_MULTIPLIER) - } + if (downloadIds.isEmpty()) { + return 0 + } + + val totalSizeBytes = progress.totalSizeBytes + .filterKeys { downloadIds.contains(it) } + .values + .sum() + + 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 -- GitLab From 5ae48f16cd221c04c2df63dc31c3ede487bf48d0 Mon Sep 17 00:00:00 2001 From: Fahim Masud Choudhury Date: Tue, 3 Feb 2026 16:31:07 +0600 Subject: [PATCH 13/17] refactor: improve readability on PwaManager --- app/src/main/java/foundation/e/apps/install/pkg/PwaManager.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/foundation/e/apps/install/pkg/PwaManager.kt b/app/src/main/java/foundation/e/apps/install/pkg/PwaManager.kt index 8c30b945c..c505af2d9 100644 --- a/app/src/main/java/foundation/e/apps/install/pkg/PwaManager.kt +++ b/app/src/main/java/foundation/e/apps/install/pkg/PwaManager.kt @@ -121,12 +121,12 @@ class PwaManager @Inject constructor( fun getInstalledPwaUrls(): Set { return context.contentResolver.query( PWA_PLAYER.toUri(), - arrayOf("url"), + arrayOf(URL.lowercase()), null, null, null )?.use { cursor -> - val urlIndex = cursor.getColumnIndex("url") + val urlIndex = cursor.getColumnIndex(URL.lowercase()) if (urlIndex == -1) { return@use emptySet() } -- GitLab From b520cef51951d4cb4c9d47a01554c90464114be6 Mon Sep 17 00:00:00 2001 From: Fahim Masud Choudhury Date: Tue, 3 Feb 2026 16:35:07 +0600 Subject: [PATCH 14/17] refactor: improve readability on InstallButtonState --- .../ui/compose/state/InstallButtonState.kt | 32 ++++--------------- 1 file changed, 7 insertions(+), 25 deletions(-) diff --git a/app/src/main/java/foundation/e/apps/ui/compose/state/InstallButtonState.kt b/app/src/main/java/foundation/e/apps/ui/compose/state/InstallButtonState.kt index 878a8ca60..5aa16d20b 100644 --- a/app/src/main/java/foundation/e/apps/ui/compose/state/InstallButtonState.kt +++ b/app/src/main/java/foundation/e/apps/ui/compose/state/InstallButtonState.kt @@ -45,40 +45,25 @@ data class InstallButtonState( } } -/* - * UI label that can be backed by a string resource or a literal string (e.g., price). - */ data class ButtonLabel( @StringRes val resId: Int? = null, val text: String? = null, ) -/* - * Visual treatment tokens mapped to Compose theme in the UI layer. - */ enum class InstallButtonStyle { - AccentFill, // Accent background, white text (matches installed/updatable legacy state) - AccentOutline, // Transparent background, accent stroke + text - Disabled, // Light-grey stroke + text + AccentFill, + AccentOutline, + Disabled, } -/* - * Intent describing the side-effect the UI host should execute on click. - */ enum class InstallButtonAction { Install, CancelDownload, OpenAppOrPwa, UpdateSelfConfirm, ShowPaidDialog, ShowBlockedSnackbar, NoOp, } -/* - * Dialog variants initiated from the button state when action requires confirmation. - */ enum class InstallDialogType { SelfUpdateConfirmation, PaidAppDialog, } -/* - * Lightweight status marker useful for tests and debugging to assert mapping branch taken. - */ enum class StatusTag { Installed, Updatable, @@ -92,12 +77,9 @@ enum class StatusTag { Unknown, } -/* - * Purchase resolution states, mirroring legacy branching. - */ sealed class PurchaseState { - object Unknown : PurchaseState() - object Loading : PurchaseState() - object Purchased : PurchaseState() - object NotPurchased : PurchaseState() + data object Unknown : PurchaseState() + data object Loading : PurchaseState() + data object Purchased : PurchaseState() + data object NotPurchased : PurchaseState() } -- GitLab From f64f8143c53153824911dc20088268db0be9d2d6 Mon Sep 17 00:00:00 2001 From: Fahim Masud Choudhury Date: Tue, 3 Feb 2026 17:08:54 +0600 Subject: [PATCH 15/17] refactor: improve readability on InstallStatusReconciler --- .../compose/state/InstallStatusReconciler.kt | 46 +++++++++++++------ 1 file changed, 33 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/foundation/e/apps/ui/compose/state/InstallStatusReconciler.kt b/app/src/main/java/foundation/e/apps/ui/compose/state/InstallStatusReconciler.kt index 7ff52ff34..97d843125 100644 --- a/app/src/main/java/foundation/e/apps/ui/compose/state/InstallStatusReconciler.kt +++ b/app/src/main/java/foundation/e/apps/ui/compose/state/InstallStatusReconciler.kt @@ -78,21 +78,41 @@ class InstallStatusReconciler @Inject constructor( if (progress == null) { return null } + val percent = appManagerWrapper.calculateProgress(activeDownload, progress) - var result: Int? = percent.takeIf { it in 0..PERCENTAGE_MAX } - if (result == null) { - val id = progress.downloadId.takeIf { it != INVALID_DOWNLOAD_ID } - val hasDownloadId = id != null && activeDownload.downloadIdMap.containsKey(id) - if (hasDownloadId) { - val total = progress.totalSizeBytes[id] ?: 0L - if (total > 0) { - val done = progress.bytesDownloadedSoFar[id] ?: 0L - result = ((done / total.toDouble()) * PERCENTAGE_MAX) - .toInt() - .coerceIn(0, PERCENTAGE_MAX) - } - } + val downloadId = progress.downloadId.takeIf { it != INVALID_DOWNLOAD_ID } + val hasDownloadId = + downloadId != null && activeDownload.downloadIdMap.containsKey(downloadId) + + val result = if (percent in 0..PERCENTAGE_MAX) { + percent + } else { + calculatePercent(progress, downloadId, hasDownloadId) } + + return result + } + + private fun calculatePercent( + progress: DownloadProgress, + downloadId: Long?, + hasDownloadId: Boolean, + ): Int? { + val total = if (hasDownloadId && downloadId != null) { + progress.totalSizeBytes[downloadId] ?: 0L + } else { + 0L + } + + val result = if (total > 0 && downloadId != null) { + val done = progress.bytesDownloadedSoFar[downloadId] ?: 0L + ((done / total.toDouble()) * PERCENTAGE_MAX) + .toInt() + .coerceIn(0, PERCENTAGE_MAX) + } else { + null + } + return result } -- GitLab From 4a2760ecdb86ed50c6050a94574a6e9adbd82859 Mon Sep 17 00:00:00 2001 From: Fahim Masud Choudhury Date: Tue, 3 Feb 2026 17:28:14 +0600 Subject: [PATCH 16/17] refactor: improve readability on SearchFragmentV2 --- .../foundation/e/apps/ui/search/v2/SearchFragmentV2.kt | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/foundation/e/apps/ui/search/v2/SearchFragmentV2.kt b/app/src/main/java/foundation/e/apps/ui/search/v2/SearchFragmentV2.kt index 264941bad..ecd49805d 100644 --- a/app/src/main/java/foundation/e/apps/ui/search/v2/SearchFragmentV2.kt +++ b/app/src/main/java/foundation/e/apps/ui/search/v2/SearchFragmentV2.kt @@ -217,14 +217,12 @@ class SearchFragmentV2 : Fragment(R.layout.fragment_search_v2) { mainActivityViewModel.getApplication(application) }, cancelButtonText = getString(R.string.dialog_cancel), - ).show(childFragmentManager, "SearchFragmentV2") + ).show(childFragmentManager, SearchFragmentV2::class.java.name) } private fun showPaidSnackbar() { - view?.let { - Snackbar.make(it, getString(R.string.paid_app_anonymous_message), Snackbar.LENGTH_SHORT) - .show() - } + Snackbar.make(requireView(), R.string.paid_app_anonymous_message, Snackbar.LENGTH_SHORT) + .show() } private fun showBlockedSnackbar() { @@ -235,7 +233,7 @@ class SearchFragmentV2 : Fragment(R.layout.fragment_search_v2) { User.GOOGLE -> getString(R.string.install_blocked_google) } if (errorMsg.isNotBlank()) { - view?.let { Snackbar.make(it, errorMsg, Snackbar.LENGTH_SHORT).show() } + Snackbar.make(requireView(), errorMsg, Snackbar.LENGTH_SHORT).show() } } -- GitLab From 970a3c15c21b368d53ffbe5241d4d84cb6f5ff5a Mon Sep 17 00:00:00 2001 From: Fahim Masud Choudhury Date: Tue, 3 Feb 2026 18:33:44 +0600 Subject: [PATCH 17/17] refactor: improve SearchFragmentV2 code organization Break out Compose setup, progress collection, and install-state derivation into focused helpers to improve readability while preserving behavior. --- .../e/apps/ui/search/v2/SearchFragmentV2.kt | 233 ++++++++++++------ 1 file changed, 155 insertions(+), 78 deletions(-) diff --git a/app/src/main/java/foundation/e/apps/ui/search/v2/SearchFragmentV2.kt b/app/src/main/java/foundation/e/apps/ui/search/v2/SearchFragmentV2.kt index ecd49805d..3e472f97c 100644 --- a/app/src/main/java/foundation/e/apps/ui/search/v2/SearchFragmentV2.kt +++ b/app/src/main/java/foundation/e/apps/ui/search/v2/SearchFragmentV2.kt @@ -20,6 +20,7 @@ package foundation.e.apps.ui.search.v2 import android.os.Bundle import android.view.View +import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -63,100 +64,176 @@ class SearchFragmentV2 : Fragment(R.layout.fragment_search_v2) { private val appProgressViewModel: AppProgressViewModel by viewModels() private val appInfoFetchViewModel: AppInfoFetchViewModel by viewModels() - @Suppress("LongMethod") override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - val composeView = view.findViewById(R.id.composeView) - composeView.setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + val composeView = setupComposeView(view) + startDownloadProgressCollection() + setComposeContent(composeView) + } + + private fun setupComposeView(view: View): ComposeView { + return view.findViewById(R.id.composeView).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + } + } - // Ensure DownloadProgress emissions reach the ViewModel even if Compose does not recompose. + // Ensure DownloadProgress emissions reach the ViewModel even if Compose does not recompose. + private fun startDownloadProgressCollection() { viewLifecycleOwner.lifecycleScope.launch { appProgressViewModel.downloadProgress.asFlow().collect { progress -> searchViewModel.updateDownloadProgress(copyProgress(progress)) } } + } + private fun setComposeContent(composeView: ComposeView) { composeView.setContent { AppTheme { - val uiState by searchViewModel.uiState.collectAsStateWithLifecycle() - val user = mainActivityViewModel.getUser() - val isAnonymous = user == User.ANONYMOUS - val downloadProgress by appProgressViewModel.downloadProgress.observeAsState() - val progressPercentMap by searchViewModel.progressPercentByKey.collectAsState() - val statusByKey by searchViewModel.statusByKey.collectAsState() - - LaunchedEffect(downloadProgress) { - // Retain Compose-based updates as a secondary path for safety. - downloadProgress?.let { - searchViewModel.updateDownloadProgress(copyProgress(it)) - } - } + SearchScreenContent() + } + } + } - val installButtonStateProvider: (Application) -> InstallButtonState = { app -> - val progressKey = app.package_name.takeIf { it.isNotBlank() } ?: app._id - val progressPercent = progressPercentMap[progressKey] - val overrideStatus = statusByKey[progressKey] - val purchaseState = when { - app.isFree -> PurchaseState.Unknown - app.isPurchased -> PurchaseState.Purchased - else -> PurchaseState.NotPurchased - } - val isBlocked = appInfoFetchViewModel.isAppInBlockedList(app) - val isUnsupported = - app.filterLevel.isInitialized() && !app.filterLevel.isUnFiltered() - val originalStatus = app.status - val effectiveApp = when { - isBlocked -> { - app.status = Status.BLOCKED - app - } - - else -> app - } - mapAppToInstallState( - InstallButtonStateInput( - app = effectiveApp, - user = user, - isAnonymousUser = isAnonymous, - isUnsupported = isUnsupported, - installationFault = null, - purchaseState = purchaseState, - progressPercent = progressPercent, - isSelfUpdate = app.package_name == requireContext().packageName, - overrideStatus = overrideStatus, - ) - ) - .also { - // Restore original status to avoid mutating shared paging instance. - app.status = originalStatus - } - } + @Composable + private fun SearchScreenContent() { + val uiState by searchViewModel.uiState.collectAsStateWithLifecycle() + val user = mainActivityViewModel.getUser() + val isAnonymous = user == User.ANONYMOUS + val downloadProgress by appProgressViewModel.downloadProgress.observeAsState() + val progressPercentMap by searchViewModel.progressPercentByKey.collectAsState() + val statusByKey by searchViewModel.statusByKey.collectAsState() + val selfPackageName = requireContext().packageName - SearchScreen( - uiState = uiState, - onQueryChange = searchViewModel::onQueryChanged, - onBackClick = { requireActivity().onBackPressedDispatcher.onBackPressed() }, - onClearQuery = searchViewModel::onQueryCleared, - onSubmitSearch = searchViewModel::onSearchSubmitted, - onSuggestionSelect = searchViewModel::onSuggestionSelected, - onTabSelect = searchViewModel::onTabSelected, - fossPaging = searchViewModel.fossPagingFlow, - pwaPaging = searchViewModel.pwaPagingFlow, - playStorePaging = searchViewModel.playStorePagingFlow, - searchVersion = uiState.searchVersion, - getScrollPosition = { tab -> searchViewModel.getScrollPosition(tab) }, - onScrollPositionChange = { tab, index, offset -> - searchViewModel.updateScrollPosition(tab, index, offset) - }, - onResultClick = { application -> navigateToApplication(application) }, - onPrimaryAction = { application, action -> - handlePrimaryAction(application, action) - }, - installButtonStateProvider = installButtonStateProvider, - ) + DownloadProgressEffect(downloadProgress) + + val installButtonStateProvider = buildInstallButtonStateProvider( + user = user, + isAnonymous = isAnonymous, + progressPercentMap = progressPercentMap, + statusByKey = statusByKey, + selfPackageName = selfPackageName, + ) + + SearchScreen( + uiState = uiState, + onQueryChange = searchViewModel::onQueryChanged, + onBackClick = { requireActivity().onBackPressedDispatcher.onBackPressed() }, + onClearQuery = searchViewModel::onQueryCleared, + onSubmitSearch = searchViewModel::onSearchSubmitted, + onSuggestionSelect = searchViewModel::onSuggestionSelected, + onTabSelect = searchViewModel::onTabSelected, + fossPaging = searchViewModel.fossPagingFlow, + pwaPaging = searchViewModel.pwaPagingFlow, + playStorePaging = searchViewModel.playStorePagingFlow, + searchVersion = uiState.searchVersion, + getScrollPosition = { tab -> searchViewModel.getScrollPosition(tab) }, + onScrollPositionChange = { tab, index, offset -> + searchViewModel.updateScrollPosition(tab, index, offset) + }, + onResultClick = { application -> navigateToApplication(application) }, + onPrimaryAction = { application, action -> + handlePrimaryAction(application, action) + }, + installButtonStateProvider = installButtonStateProvider, + ) + } + + @Composable + private fun DownloadProgressEffect(downloadProgress: DownloadProgress?) { + LaunchedEffect(downloadProgress) { + // Retain Compose-based updates as a secondary path for safety. + downloadProgress?.let { + searchViewModel.updateDownloadProgress(copyProgress(it)) } } } + private fun buildInstallButtonStateProvider( + user: User, + isAnonymous: Boolean, + progressPercentMap: Map, + statusByKey: Map, + selfPackageName: String, + ): (Application) -> InstallButtonState { + return { app -> + val progressKey = progressKeyFor(app) + val progressPercent = progressPercentMap[progressKey] + val overrideStatus = statusByKey[progressKey] + val purchaseState = purchaseStateFor(app) + val isBlocked = appInfoFetchViewModel.isAppInBlockedList(app) + val isUnsupported = isUnsupportedApp(app) + + mapInstallButtonState( + app = app, + installButtonContext = InstallButtonContext( + user = user, + isAnonymous = isAnonymous, + isUnsupported = isUnsupported, + purchaseState = purchaseState, + progressPercent = progressPercent, + overrideStatus = overrideStatus, + isBlocked = isBlocked, + selfPackageName = selfPackageName, + ) + ) + } + } + + private fun progressKeyFor(app: Application): String { + return app.package_name.takeIf { it.isNotBlank() } ?: app._id + } + + private fun purchaseStateFor(app: Application): PurchaseState { + return when { + app.isFree -> PurchaseState.Unknown + app.isPurchased -> PurchaseState.Purchased + else -> PurchaseState.NotPurchased + } + } + + private fun isUnsupportedApp(app: Application): Boolean { + return app.filterLevel.isInitialized() && !app.filterLevel.isUnFiltered() + } + + private fun mapInstallButtonState( + app: Application, + installButtonContext: InstallButtonContext, + ): InstallButtonState { + val originalStatus = app.status + if (installButtonContext.isBlocked) { + app.status = Status.BLOCKED + } + + return try { + mapAppToInstallState( + InstallButtonStateInput( + app = app, + user = installButtonContext.user, + isAnonymousUser = installButtonContext.isAnonymous, + isUnsupported = installButtonContext.isUnsupported, + installationFault = null, + purchaseState = installButtonContext.purchaseState, + progressPercent = installButtonContext.progressPercent, + isSelfUpdate = app.package_name == installButtonContext.selfPackageName, + overrideStatus = installButtonContext.overrideStatus, + ) + ) + } finally { + // Restore original status to avoid mutating shared paging instance. + app.status = originalStatus + } + } + + private data class InstallButtonContext( + val user: User, + val isAnonymous: Boolean, + val isUnsupported: Boolean, + val purchaseState: PurchaseState, + val progressPercent: Int?, + val overrideStatus: Status?, + val isBlocked: Boolean, + val selfPackageName: String, + ) + private fun copyProgress(progress: DownloadProgress): DownloadProgress { return DownloadProgress( totalSizeBytes = progress.totalSizeBytes.toMutableMap(), -- GitLab