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 c0da2983bb12bb02b02b4dc64e0dd81ae58dc61d..0a538c4ce12fae9d24f2bcb3ef9f3f7245fec4e8 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 6b7ae14eb439ed6d94034d79ebb222cdd7a43de1..b673259f9b27d323f9c2d41fd7313035afa03171 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() @@ -211,6 +224,31 @@ class SearchResultsContentTest { ).assertIsDisplayed() } + @Test + fun appendError_showsFooterRetryWithResults() { + val pagingData = PagingData.from( + listOf(sampleApp("Loaded App")), + sourceLoadStates = loadStates( + refresh = LoadState.NotLoading(endOfPaginationReached = false), + append = LoadState.Error(RuntimeException("append boom")) + ) + ) + + renderSearchResults( + tabs = listOf(SearchTabType.OPEN_SOURCE), + selectedTab = SearchTabType.OPEN_SOURCE, + fossPagingData = pagingData, + ) + + composeRule.onNodeWithText("Loaded App").assertIsDisplayed() + composeRule.onNodeWithText( + composeRule.activity.getString(R.string.search_error) + ).assertIsDisplayed() + composeRule.onNodeWithText( + composeRule.activity.getString(R.string.retry) + ).assertIsDisplayed() + } + @Test fun emptyResults_showsPlaceholder() { val pagingData = PagingData.empty( @@ -229,6 +267,60 @@ class SearchResultsContentTest { composeRule.onNodeWithText(noAppsText).assertIsDisplayed() } + @Test + fun emptyResults_resetOnNewQuery_showsRefreshLoading() { + val noAppsText = composeRule.activity.getString(R.string.no_apps_found) + val emptyPagingData = PagingData.empty( + 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( @@ -250,18 +342,114 @@ class SearchResultsContentTest { .assertIsDisplayed() } + @Test + fun primaryAction_prefers_literal_label_text() { + renderSearchResults( + tabs = listOf(SearchTabType.OPEN_SOURCE), + selectedTab = SearchTabType.OPEN_SOURCE, + fossPagingData = pagingData(listOf(sampleApp("Paid App"))), + installButtonStateProvider = { + InstallButtonState(label = ButtonLabel(text = "$2.99")) + } + ) + + composeRule.onNodeWithText("$2.99").assertIsDisplayed() + } + + @Test + fun primaryAction_uses_progress_percent_when_no_label_text() { + renderSearchResults( + tabs = listOf(SearchTabType.OPEN_SOURCE), + selectedTab = SearchTabType.OPEN_SOURCE, + fossPagingData = pagingData(listOf(sampleApp("Downloading App"))), + installButtonStateProvider = { + InstallButtonState( + progressPercentText = "45%", + actionIntent = InstallButtonAction.CancelDownload, + ) + } + ) + + composeRule.onNodeWithText("45%").assertIsDisplayed() + } + + @Test + fun primaryAction_uses_resource_label_when_no_text_or_percent() { + val openLabel = composeRule.activity.getString(R.string.open) + + renderSearchResults( + tabs = listOf(SearchTabType.OPEN_SOURCE), + selectedTab = SearchTabType.OPEN_SOURCE, + fossPagingData = pagingData(listOf(sampleApp("Open App"))), + installButtonStateProvider = { + InstallButtonState(label = ButtonLabel(resId = R.string.open)) + } + ) + + composeRule.onNodeWithText(openLabel).assertIsDisplayed() + } + + @Test + fun primaryAction_shows_spinner_when_in_progress_and_blank() { + renderSearchResults( + tabs = listOf(SearchTabType.OPEN_SOURCE), + selectedTab = SearchTabType.OPEN_SOURCE, + fossPagingData = pagingData(listOf(sampleApp("Installing App"))), + installButtonStateProvider = { + InstallButtonState( + label = ButtonLabel(text = ""), + showProgressBar = true, + ) + } + ) + + composeRule.onNodeWithTag(SearchResultListItemTestTags.PRIMARY_PROGRESS) + .assertIsDisplayed() + } + + @Test + fun primaryAction_click_forwards_action_intent() { + var capturedAction: InstallButtonAction? = null + + renderSearchResults( + tabs = listOf(SearchTabType.OPEN_SOURCE), + selectedTab = SearchTabType.OPEN_SOURCE, + fossPagingData = pagingData(listOf(sampleApp("Update App"))), + installButtonStateProvider = { + InstallButtonState( + label = ButtonLabel(text = "Update"), + actionIntent = InstallButtonAction.UpdateSelfConfirm, + ) + }, + onPrimaryActionClick = { _, action -> capturedAction = action } + ) + + composeRule.onNodeWithTag(SearchResultListItemTestTags.PRIMARY_BUTTON) + .performClick() + + composeRule.runOnIdle { + assertEquals(InstallButtonAction.UpdateSelfConfirm, capturedAction) + } + } + private fun renderSearchResults( tabs: List, 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 +458,13 @@ class SearchResultsContentTest { selectedTab = selectedTab, fossItems = fossItems, pwaItems = pwaItems, + playStoreItems = playStoreItems, searchVersion = searchVersion, getScrollPosition = { null }, onScrollPositionChange = { _, _, _ -> }, onTabSelect = {}, + onPrimaryActionClick = onPrimaryActionClick, + installButtonStateProvider = installButtonStateProvider, ) } } @@ -294,4 +485,8 @@ class SearchResultsContentTest { refresh = LoadState.NotLoading(endOfPaginationReached = false) ) ) + + private fun defaultInstallButtonState() = InstallButtonState( + label = ButtonLabel(resId = R.string.open), + ) } 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 0000000000000000000000000000000000000000..8662ae8b33f969bb2d7e606ef122c90fb6461450 --- /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/main/java/foundation/e/apps/data/Stores.kt b/app/src/main/java/foundation/e/apps/data/Stores.kt index ed6f48ec67b16815e66867f0a37c8250f9b822e6..ca076e86cf386adb2a1ad317712434ecdd56656b 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/data/install/AppManagerWrapper.kt b/app/src/main/java/foundation/e/apps/data/install/AppManagerWrapper.kt index ab66813ce100b9048142223e8142ab72670b2bec..4fce1f9681f92bd13b94889be96c26de65fa4548 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 @@ -115,52 +133,39 @@ class AppManagerWrapper @Inject constructor( val appDownload = getDownloadList() .singleOrNull { it.id.contentEquals(app._id) && it.packageName.contentEquals(app.package_name) } ?: return 0 + return calculateProgress(appDownload, progress) + } + return 0 + } - if (!appDownload.id.contentEquals(app._id) || !appDownload.packageName.contentEquals(app.package_name)) { - return@let - } - - if (!isProgressValidForApp(application, progress)) { - return -1 - } + suspend fun calculateProgress( + appInstall: AppInstall, + progress: DownloadProgress + ): Int { + val downloadIds = appInstall.downloadIdMap.keys + if (downloadIds.isEmpty()) { + return 0 + } - val downloadingMap = progress.totalSizeBytes.filter { item -> - appDownload.downloadIdMap.keys.contains(item.key) && item.value > 0 - } + val totalSizeBytes = progress.totalSizeBytes + .filterKeys { downloadIds.contains(it) } + .values + .sum() - 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() + val percent = if (totalSizeBytes > 0) { + ((downloadedSoFar / totalSizeBytes.toDouble()) * PERCENTAGE_MULTIPLIER) + .toInt() + .coerceIn(0, PERCENTAGE_MULTIPLIER) + } else { + 0 } - return 0 - } - private suspend fun isProgressValidForApp( - application: Application, - downloadProgress: DownloadProgress - ): Boolean { - val download = getDownloadList().singleOrNull { - it.id == application._id && it.packageName == application.package_name - } ?: return false - - /* - * 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) - } + return percent } fun handleRatingFormat(rating: Double): String? { @@ -195,7 +200,10 @@ class AppManagerWrapper @Inject constructor( return Pair(1, 0) } - fun getDownloadingItemStatus(application: Application?, downloadList: List): 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/data/search/PlayStoreAppMapperImpl.kt b/app/src/main/java/foundation/e/apps/data/search/PlayStoreAppMapperImpl.kt new file mode 100644 index 0000000000000000000000000000000000000000..647ad1eceb12a50883e2b5c436519db052f07f01 --- /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 54d3f25190444a13b562eb52904c2877cd5b25b9..d0aa29ffbaf7fa657581d53f8cd74433782d6627 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 0000000000000000000000000000000000000000..d65939f2294257119209ffe12253e34dd0a0fcc6 --- /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.enums.Source +import foundation.e.apps.data.search.CleanApkSearchParams +import foundation.e.apps.data.search.SearchPagingRepository +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, + source: Source, + appSource: String, + appType: String, + ): Flow> { + return requests + .filterNotNull() + .mapLatest { request -> + if (!request.enabledSources.contains(source) || 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/FetchSearchSuggestionsUseCase.kt b/app/src/main/java/foundation/e/apps/domain/search/FetchSearchSuggestionsUseCase.kt new file mode 100644 index 0000000000000000000000000000000000000000..49ed7ff372459bc81662ba0a99ca0a6ce73f5206 --- /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/PlayStoreAppMapper.kt b/app/src/main/java/foundation/e/apps/domain/search/PlayStoreAppMapper.kt new file mode 100644 index 0000000000000000000000000000000000000000..79100060502f3cd64d0c41c4953bc180ab558e47 --- /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 0000000000000000000000000000000000000000..0ab2150f173b1564acc34e36d01005ca2c81df1d --- /dev/null +++ b/app/src/main/java/foundation/e/apps/domain/search/PlayStoreSearchPagingUseCase.kt @@ -0,0 +1,63 @@ +/* + * 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.enums.Source +import foundation.e.apps.data.search.PlayStorePagingRepository +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, + ): Flow> { + return requests + .filterNotNull() + .mapLatest { request -> + if (!request.enabledSources.contains(Source.PLAY_STORE) || 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/PrepareSearchSubmissionUseCase.kt b/app/src/main/java/foundation/e/apps/domain/search/PrepareSearchSubmissionUseCase.kt new file mode 100644 index 0000000000000000000000000000000000000000..d798460a7fd11484c3dab3c75faa9d8fede6cfd2 --- /dev/null +++ b/app/src/main/java/foundation/e/apps/domain/search/PrepareSearchSubmissionUseCase.kt @@ -0,0 +1,59 @@ +/* + * 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 javax.inject.Inject + +class PrepareSearchSubmissionUseCase @Inject constructor( + private val stores: Stores, +) { + operator fun invoke( + submittedQuery: String, + selectedSource: Source?, + currentVersion: Int, + ): SearchSubmissionResult { + val trimmedQuery = submittedQuery.trim() + val enabledSources = stores.getEnabledSearchSources() + 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() && enabledSources.isNotEmpty() + val searchRequest = if (hasSubmittedSearch) { + SearchRequest( + query = trimmedQuery, + enabledSources = enabledSources, + version = nextVersion, + ) + } else { + null + } + + return SearchSubmissionResult( + trimmedQuery = trimmedQuery, + enabledSources = enabledSources, + selectedSource = resolvedSelectedSource, + hasSubmittedSearch = hasSubmittedSearch, + nextVersion = nextVersion, + searchRequest = searchRequest, + ) + } +} 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 0000000000000000000000000000000000000000..0615e84e00a24e162519dd36f917424c95fecf4c --- /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.data.enums.Source + +data class SearchRequest( + val query: String, + val enabledSources: List, + val version: Int, +) diff --git a/app/src/main/java/foundation/e/apps/domain/search/SearchSubmissionResult.kt b/app/src/main/java/foundation/e/apps/domain/search/SearchSubmissionResult.kt new file mode 100644 index 0000000000000000000000000000000000000000..81b13870b560bcad0da7cfe55a457b76498a1ed1 --- /dev/null +++ b/app/src/main/java/foundation/e/apps/domain/search/SearchSubmissionResult.kt @@ -0,0 +1,30 @@ +/* + * 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.enums.Source + +data class SearchSubmissionResult( + val trimmedQuery: String, + val enabledSources: List, + val selectedSource: Source?, + val hasSubmittedSearch: Boolean, + val nextVersion: Int, + val searchRequest: SearchRequest?, +) 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 649318dcc7f5556e8095aab3c0842031cec74ec4..c505af2d913cceb6d22da2d7e132ebb146da8dc6 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,33 @@ 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 { + return context.contentResolver.query( + PWA_PLAYER.toUri(), + arrayOf(URL.lowercase()), + null, + null, + null + )?.use { cursor -> + val urlIndex = cursor.getColumnIndex(URL.lowercase()) + if (urlIndex == -1) { + return@use emptySet() + } + val installed = mutableSetOf() + while (cursor.moveToNext()) { + val url = cursor.getString(urlIndex) + if (!url.isNullOrBlank()) { + installed.add(url) + } + } + installed + } ?: emptySet() + } + 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 5db16df9927023714265b2ee4f7a4f72e5b1f87c..c764aadcf9073b6f1fbcf468984a8f47b1a25842 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,39 +274,38 @@ 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 indicatorColor = - if (uiState.isFilledStyle) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onPrimary + val showSpinner = uiState.isInProgress && uiState.label.isBlank() + if (showSpinner) { CircularProgressIndicator( modifier = Modifier .size(16.dp) .testTag(SearchResultListItemTestTags.PRIMARY_PROGRESS), strokeWidth = 2.dp, - color = indicatorColor, + color = labelTextColor, ) } else { - val textColor = - if (uiState.isFilledStyle) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface Text( 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 +315,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 +378,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 7146f05768abd7410774b38d3a0541da0111e40f..154b4af3b0c70fc755b3e242900044d0eaca8de4 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,27 +33,28 @@ 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 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,99 +67,102 @@ 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 - } - - 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, - 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, + installButtonStateProvider = installButtonStateProvider, modifier = Modifier.fillMaxSize(), ) } @@ -167,19 +171,85 @@ 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?, + 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( @@ -205,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 { @@ -220,10 +309,11 @@ private fun PagingPlayStoreResultList( SearchShimmerList() } - isError -> { + initialLoadError -> { SearchErrorState( onRetry = { lazyItems.retry() }, - modifier = Modifier.fillMaxSize() + modifier = Modifier.fillMaxSize(), + fullScreen = true, ) } @@ -245,18 +335,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(), @@ -278,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, + ) + } + } } } } @@ -292,9 +401,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 @@ -318,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 { @@ -335,10 +464,11 @@ private fun PagingSearchResultList( ) } - isError -> { + initialLoadError -> { SearchErrorState( onRetry = { lazyItems.retry() }, - modifier = Modifier.fillMaxSize() + modifier = Modifier.fillMaxSize(), + fullScreen = true, ) } @@ -367,11 +497,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(), @@ -397,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, + ) + } + } } } } @@ -404,7 +554,7 @@ private fun PagingSearchResultList( } @Composable -private fun Application.toSearchResultUiState(): SearchResultListItemState { +private fun Application.toSearchResultUiState(buttonState: InstallButtonState): SearchResultListItemState { if (isPlaceHolder) { return SearchResultListItemState( author = "", @@ -449,51 +599,23 @@ 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, ) } -@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/components/search/SearchErrorState.kt b/app/src/main/java/foundation/e/apps/ui/compose/components/search/SearchErrorState.kt index a8ad4969d941d9b3ffadd71882951546d073ba07..964253ab57bf5b3de81dd21633f1a1c029817c6e 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/compose/screens/SearchScreen.kt b/app/src/main/java/foundation/e/apps/ui/compose/screens/SearchScreen.kt index 657adf71ef9ce84bc6decebae195423b3181551e..38cefd2b215bb0a3318f6a4f289dab3e2c2c1002 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,14 +63,15 @@ 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 = { _, _, _ -> }, onResultClick: (Application) -> Unit = {}, - 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 +148,7 @@ fun SearchScreen( selectedTab = uiState.selectedTab!!, fossItems = fossItems, pwaItems = pwaItems, + playStoreItems = playStoreItems, searchVersion = searchVersion, getScrollPosition = getScrollPosition, onScrollPositionChange = onScrollPositionChange, @@ -153,11 +156,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/InstallButtonState.kt b/app/src/main/java/foundation/e/apps/ui/compose/state/InstallButtonState.kt new file mode 100644 index 0000000000000000000000000000000000000000..5aa16d20bdc255dfc07627d09e06b3940e7c3e4e --- /dev/null +++ b/app/src/main/java/foundation/e/apps/ui/compose/state/InstallButtonState.kt @@ -0,0 +1,85 @@ +/* + * 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 + } +} + +data class ButtonLabel( + @StringRes val resId: Int? = null, + val text: String? = null, +) + +enum class InstallButtonStyle { + AccentFill, + AccentOutline, + Disabled, +} + +enum class InstallButtonAction { + Install, CancelDownload, OpenAppOrPwa, UpdateSelfConfirm, ShowPaidDialog, ShowBlockedSnackbar, NoOp, +} + +enum class InstallDialogType { + SelfUpdateConfirmation, PaidAppDialog, +} + +enum class StatusTag { + Installed, + Updatable, + UnavailableFree, + UnavailablePaid, + UnavailableUnsupported, + Downloading, + Installing, + Blocked, + InstallationIssue, + Unknown, +} + +sealed class PurchaseState { + data object Unknown : PurchaseState() + data object Loading : PurchaseState() + data object Purchased : PurchaseState() + data object NotPurchased : PurchaseState() +} 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 0000000000000000000000000000000000000000..9321c0137559ef908da3dbcacebf297badf6dd9c --- /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 new file mode 100644 index 0000000000000000000000000000000000000000..b24d7f013a45b531cd2c9232d2a0eefc48792703 --- /dev/null +++ b/app/src/main/java/foundation/e/apps/ui/compose/state/InstallButtonStateMapper.kt @@ -0,0 +1,218 @@ +/* + * 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.Status +import foundation.e.apps.data.enums.User + +/* + * Map raw application + contextual signals into a single button state. + * Keep pure: no side effects; callers handle actions. + */ +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) + } +} + +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, + ) +} + +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, + ) +} + +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, + ) +} + +private fun mapUnavailablePaid(input: InstallButtonStateInput): InstallButtonState { + return when (input.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 = 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 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() } + 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 0000000000000000000000000000000000000000..97d84312552c5e43c3685df0d139b74e2bee49e1 --- /dev/null +++ b/app/src/main/java/foundation/e/apps/ui/compose/state/InstallStatusReconciler.kt @@ -0,0 +1,123 @@ +/* + * 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) + 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 + } + + 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/compose/state/InstallStatusStream.kt b/app/src/main/java/foundation/e/apps/ui/compose/state/InstallStatusStream.kt new file mode 100644 index 0000000000000000000000000000000000000000..797938acf8fed9d7f6fe8904f87f88636dcfb959 --- /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 5759b1dbbdfdc75a1bb158692f4237f366f712af..3e472f97c09aa602397a12af99d0cb918f520e62 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,49 +20,297 @@ 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 +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.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 +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) + 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. + 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() - 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) }, + SearchScreenContent() + } + } + } + + @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 + + 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(), + 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() + } + + 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::class.java.name) + } + + private fun showPaidSnackbar() { + Snackbar.make(requireView(), R.string.paid_app_anonymous_message, Snackbar.LENGTH_SHORT) + .show() + } + + private fun showBlockedSnackbar() { + 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()) { + Snackbar.make(requireView(), errorMsg, Snackbar.LENGTH_SHORT).show() } } 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 0000000000000000000000000000000000000000..26363f4569c88d4ea54623ff49b79276fcc08325 --- /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 a0d1702a6b74e5fa7de8566746d75e5d22316ed7..b093d55eee6750f5ce29033d1bbcb129e9971b54 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 @@ -22,28 +22,31 @@ 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 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.Source +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 kotlinx.coroutines.ExperimentalCoroutinesApi +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.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.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.mapLatest import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject @@ -66,7 +69,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. */ @@ -76,15 +79,19 @@ data class ScrollPosition( ) @HiltViewModel +@Suppress("LongParameterList") class SearchViewModelV2 @Inject constructor( - private val suggestionSource: SuggestionSource, private val appLoungePreference: AppLoungePreference, - private val searchPagingRepository: SearchPagingRepository, - private val playStorePagingRepository: PlayStorePagingRepository, - private val stores: Stores + cleanApkSearchPagingUseCase: CleanApkSearchPagingUseCase, + playStoreSearchPagingUseCase: PlayStoreSearchPagingUseCase, + private val fetchSearchSuggestionsUseCase: FetchSearchSuggestionsUseCase, + private val prepareSearchSubmissionUseCase: PrepareSearchSubmissionUseCase, + private val stores: Stores, + private val installStatusStream: InstallStatusStream, + private val installStatusReconciler: InstallStatusReconciler, ) : ViewModel() { - private val initialVisibleTabs = resolveVisibleTabs() + private val initialVisibleTabs = stores.getEnabledSearchSources().toSearchTabTypes() private val _uiState = MutableStateFlow( SearchUiState( @@ -94,27 +101,33 @@ class SearchViewModelV2 @Inject constructor( ) val uiState: StateFlow = _uiState.asStateFlow() - private data class SearchRequest( - val query: String, - val visibleTabs: List, - ) - private val searchRequests = MutableStateFlow(null) private val _scrollPositions = MutableStateFlow>(emptyMap()) - - val fossPagingFlow = buildCleanApkPagingFlow( - tab = SearchTabType.OPEN_SOURCE, + 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 = cleanApkSearchPagingUseCase( + requests = searchRequests, + source = Source.OPEN_SOURCE, appSource = CleanApkRetrofit.APP_SOURCE_FOSS, - appType = CleanApkRetrofit.APP_TYPE_NATIVE - ) + appType = CleanApkRetrofit.APP_TYPE_NATIVE, + ).cachedIn(viewModelScope).withStatus() - val pwaPagingFlow = buildCleanApkPagingFlow( - tab = SearchTabType.PWA, + val pwaPagingFlow = cleanApkSearchPagingUseCase( + requests = searchRequests, + source = Source.PWA, appSource = CleanApkRetrofit.APP_SOURCE_ANY, - appType = CleanApkRetrofit.APP_TYPE_PWA - ) + appType = CleanApkRetrofit.APP_TYPE_PWA, + ).cachedIn(viewModelScope).withStatus() - val playStorePagingFlow = buildPlayStorePagingFlow() + val playStorePagingFlow = playStoreSearchPagingUseCase( + requests = searchRequests, + pageSize = DEFAULT_PLAY_STORE_PAGE_SIZE, + ).cachedIn(viewModelScope).withStatus() private var suggestionJob: Job? = null @@ -123,6 +136,7 @@ class SearchViewModelV2 @Inject constructor( stores.enabledStoresFlow .collect { handleStoreSelectionChanged() } } + observeInstallStatus() } fun onQueryChanged(newQuery: String) { @@ -132,24 +146,9 @@ class SearchViewModelV2 @Inject constructor( suggestionJob?.cancel() if (newQuery.isBlank()) { + val visibleTabs = stores.getEnabledSearchSources().toSearchTabTypes() _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 } @@ -166,7 +165,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, @@ -182,56 +181,38 @@ class SearchViewModelV2 @Inject constructor( fun onQueryCleared() { suggestionJob?.cancel() + val visibleTabs = stores.getEnabledSearchSources().toSearchTabTypes() _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, + selectedSource = currentState.selectedTab?.toSource(), + currentVersion = currentState.searchVersion, + ) + if (result.trimmedQuery.isEmpty()) { onQueryCleared() return } - val visibleTabs = resolveVisibleTabs() - - val selectedTab = _uiState.value.selectedTab?.takeIf { visibleTabs.contains(it) } - ?: visibleTabs.firstOrNull() - _uiState.update { current -> current.copy( - query = trimmedQuery, + query = result.trimmedQuery, suggestions = emptyList(), isSuggestionVisible = false, - availableTabs = visibleTabs, - selectedTab = selectedTab, - hasSubmittedSearch = visibleTabs.isNotEmpty(), - searchVersion = current.searchVersion + 1, + availableTabs = result.enabledSources.toSearchTabTypes(), + selectedTab = result.selectedSource?.toSearchTabTypeOrNull(), + hasSubmittedSearch = result.hasSubmittedSearch, + searchVersion = result.nextVersion, ) } - if (visibleTabs.isNotEmpty()) { - searchRequests.value = SearchRequest( - query = trimmedQuery, - visibleTabs = visibleTabs - ) + result.searchRequest?.let { request -> + searchRequests.value = request _scrollPositions.update { emptyMap() } } } @@ -247,32 +228,47 @@ class SearchViewModelV2 @Inject constructor( } private fun handleStoreSelectionChanged() { - val visibleTabs = resolveVisibleTabs() + val currentState = _uiState.value + 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 + } - _uiState.update { current -> - val selectedTab = current.selectedTab?.takeIf { visibleTabs.contains(it) } - ?: visibleTabs.firstOrNull() + val areSuggestionsEnabled = appLoungePreference.isPlayStoreSelected() + _uiState.update { current -> + val updatedSuggestions = if (areSuggestionsEnabled) current.suggestions else emptyList() current.copy( - availableTabs = visibleTabs, - selectedTab = selectedTab, - hasSubmittedSearch = current.hasSubmittedSearch && visibleTabs.isNotEmpty(), - isSuggestionVisible = current.isSuggestionVisible && appLoungePreference.isPlayStoreSelected(), + availableTabs = enabledSources.toSearchTabTypes(), + selectedTab = resolvedSelectedSource?.toSearchTabTypeOrNull(), + hasSubmittedSearch = updatedHasSubmittedSearch, + suggestions = updatedSuggestions, + isSuggestionVisible = current.isSuggestionVisible && areSuggestionsEnabled, ) } - val currentQuery = _uiState.value.query - val shouldUpdateRequest = _uiState.value.hasSubmittedSearch && currentQuery.isNotBlank() - if (shouldUpdateRequest && visibleTabs.isNotEmpty()) { - searchRequests.value = SearchRequest( - query = currentQuery, - visibleTabs = visibleTabs - ) - } else if (!_uiState.value.hasSubmittedSearch) { - searchRequests.value = SearchRequest( - query = "", - visibleTabs = visibleTabs - ) + searchRequest?.let { request -> + searchRequests.value = request } } @@ -284,61 +280,94 @@ class SearchViewModelV2 @Inject constructor( } } - 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 observeInstallStatus() { + viewModelScope.launch { + installStatusStream.stream(viewModelScope).collect { snapshot -> + statusSnapshot.value = snapshot } } + // TODO: wire DownloadProgress from AppProgressViewModel when available. + } - @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 - ) - ) - } + fun updateDownloadProgress(progress: DownloadProgress?) { + downloadProgress.value = progress + } + + private fun Flow>.withStatus(): Flow> = + combine( + this, + statusSnapshot.filterNotNull(), + downloadProgress + ) { paging: PagingData, snapshot: StatusSnapshot, progress: DownloadProgress? -> + paging.map { app -> reconcile(app, snapshot, progress) } } - .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 - ) - } + + 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 } - .flatMapLatest { it } - .cachedIn(viewModelScope) + } + } + + 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 } } + +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/data/StoresTest.kt b/app/src/test/java/foundation/e/apps/data/StoresTest.kt index 46358314364cd88bfbe37620e4cc08c8e3c3f690..674f977965facc37d1dc12b65c910f24719bdada 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/data/install/AppManagerWrapperProgressTest.kt b/app/src/test/java/foundation/e/apps/data/install/AppManagerWrapperProgressTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..071aeffb826fa0f3819f32c7f3bce6029cb9d66b --- /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/domain/search/CleanApkSearchPagingUseCaseTest.kt b/app/src/test/java/foundation/e/apps/domain/search/CleanApkSearchPagingUseCaseTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..7cdfcf23d5328ca10af1bb22313fc182ebbf2dea --- /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.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.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 = "", enabledSources = listOf(Source.OPEN_SOURCE), version = 1) + ) + + val pagingData = useCase( + requests = requests, + source = Source.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", enabledSources = emptyList(), version = 1) + ) + + val pagingData = useCase( + requests = requests, + source = Source.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", enabledSources = listOf(Source.OPEN_SOURCE), version = 2) + ) + + useCase( + requests = requests, + source = Source.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/FetchSearchSuggestionsUseCaseTest.kt b/app/src/test/java/foundation/e/apps/domain/search/FetchSearchSuggestionsUseCaseTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..c5c2d1cea06bac65ebbe65e5bd497461fb8bd535 --- /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/PlayStoreSearchPagingUseCaseTest.kt b/app/src/test/java/foundation/e/apps/domain/search/PlayStoreSearchPagingUseCaseTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..a79a5d0a54e67e128ffe70ba02675107a689d890 --- /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.enums.Source +import foundation.e.apps.data.search.PlayStorePagingRepository +import foundation.e.apps.ui.applicationlist.ApplicationDiffUtil +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 = "", enabledSources = listOf(Source.PLAY_STORE), 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", enabledSources = 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", enabledSources = listOf(Source.PLAY_STORE), 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", enabledSources = listOf(Source.PLAY_STORE), version = 1) + ) + val flow = useCase(requests, pageSize = 20) + + flow.first() + requests.value = SearchRequest( + query = "apps", + enabledSources = listOf(Source.PLAY_STORE), + 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/domain/search/PrepareSearchSubmissionUseCaseTest.kt b/app/src/test/java/foundation/e/apps/domain/search/PrepareSearchSubmissionUseCaseTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..f8bba4590762f2473924ffbc3d09a8ed221022e1 --- /dev/null +++ b/app/src/test/java/foundation/e/apps/domain/search/PrepareSearchSubmissionUseCaseTest.kt @@ -0,0 +1,97 @@ +/* + * 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 io.mockk.every +import io.mockk.mockk +import org.junit.Before +import org.junit.Test + +class PrepareSearchSubmissionUseCaseTest { + + private lateinit var stores: Stores + private lateinit var useCase: PrepareSearchSubmissionUseCase + + @Before + fun setUp() { + stores = mockk() + useCase = PrepareSearchSubmissionUseCase(stores) + } + + @Test + fun `blank submission does not increment version or create request`() { + every { stores.getEnabledSearchSources() } returns listOf(Source.PLAY_STORE) + + val result = useCase( + submittedQuery = " ", + selectedSource = Source.PLAY_STORE, + currentVersion = 3, + ) + + assertThat(result.trimmedQuery).isEmpty() + assertThat(result.nextVersion).isEqualTo(3) + assertThat(result.searchRequest).isNull() + assertThat(result.hasSubmittedSearch).isFalse() + assertThat(result.selectedSource).isEqualTo(Source.PLAY_STORE) + } + + @Test + fun `non-blank submission increments version without enabled sources`() { + every { stores.getEnabledSearchSources() } returns emptyList() + + val result = useCase( + submittedQuery = "apps", + selectedSource = Source.OPEN_SOURCE, + currentVersion = 2, + ) + + assertThat(result.nextVersion).isEqualTo(3) + assertThat(result.enabledSources).isEmpty() + assertThat(result.searchRequest).isNull() + assertThat(result.hasSubmittedSearch).isFalse() + assertThat(result.selectedSource).isNull() + } + + @Test + fun `valid submission returns request with resolved source`() { + every { stores.getEnabledSearchSources() } returns listOf( + Source.PLAY_STORE, + Source.OPEN_SOURCE, + ) + + val result = useCase( + submittedQuery = " notes ", + selectedSource = Source.PWA, + currentVersion = 1, + ) + + assertThat(result.nextVersion).isEqualTo(2) + assertThat(result.selectedSource).isEqualTo(Source.PLAY_STORE) + assertThat(result.hasSubmittedSearch).isTrue() + assertThat(result.searchRequest).isNotNull() + assertThat(result.searchRequest?.query).isEqualTo("notes") + assertThat(result.searchRequest?.enabledSources).containsExactly( + Source.PLAY_STORE, + Source.OPEN_SOURCE, + ).inOrder() + } +} 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 0000000000000000000000000000000000000000..1f7e799f7c0c5f3b71dc9be0731b49a9e9e1b554 --- /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 new file mode 100644 index 0000000000000000000000000000000000000000..f81b483da06ef55aca47f350604a655a95de7ef6 --- /dev/null +++ b/app/src/test/java/foundation/e/apps/ui/compose/state/InstallButtonStateMapperTest.kt @@ -0,0 +1,320 @@ +/* + * 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 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, + 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( + input = defaultInput(app = baseApp(Status.INSTALLED)), + ) + 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( + input = defaultInput( + app = baseApp(Status.UPDATABLE), + 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( + input = defaultInput( + app = baseApp(Status.UPDATABLE), + isUnsupported = true, + ), + ) + 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( + input = defaultInput(app = baseApp(Status.UNAVAILABLE, isFree = true)), + ) + 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( + input = defaultInput( + app = baseApp(Status.UNAVAILABLE, isFree = false, price = PAID_PRICE), + user = User.ANONYMOUS, + isAnonymousUser = true, + purchaseState = PurchaseState.NotPurchased, + ), + ) + assertEquals(PAID_PRICE, state.label.text) + assertEquals(InstallButtonAction.ShowPaidDialog, state.actionIntent) + assertEquals(InstallDialogType.PaidAppDialog, state.dialogType) + assertEquals(StatusTag.UnavailablePaid, state.statusTag) + } + + @Test + fun unavailable_paid_loading_disables_with_progress() { + val state = mapAppToInstallState( + input = defaultInput( + app = baseApp(Status.UNAVAILABLE, isFree = false, price = PAID_PRICE), + purchaseState = PurchaseState.Loading, + ), + ) + assertFalse(state.enabled) + assertTrue(state.showProgressBar) + assertEquals(StatusTag.UnavailablePaid, state.statusTag) + } + + @Test + fun unavailable_paid_purchased_installs() { + val state = mapAppToInstallState( + 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) + assertEquals(StatusTag.UnavailablePaid, state.statusTag) + } + + @Test + fun unavailable_unsupported_noop() { + val state = mapAppToInstallState( + 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) + assertEquals(StatusTag.UnavailableUnsupported, state.statusTag) + } + + @Test + fun downloading_with_progress_shows_percent_and_cancel_intent() { + val state = mapAppToInstallState( + input = defaultInput( + app = baseApp(Status.DOWNLOADING), + progressPercent = PROGRESS_PERCENT_VALID, + ), + ) + assertEquals("${PROGRESS_PERCENT_VALID}%", state.label.text) + assertEquals(InstallButtonAction.CancelDownload, state.actionIntent) + assertEquals(StatusTag.Downloading, state.statusTag) + } + + @Test + fun downloading_without_progress_shows_cancel_label() { + val state = mapAppToInstallState( + 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( + input = defaultInput(app = baseApp(Status.INSTALLING)), + ) + 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( + 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( + input = defaultInput( + app = baseApp(Status.BLOCKED), + user = User.GOOGLE, + isAnonymousUser = false, + ), + ) + assertEquals(R.string.install_blocked_google, stateGoogle.snackbarMessageId) + } + + @Test + fun installation_issue_faulty_disables_and_uses_retry_or_update() { + val faultyState = mapAppToInstallState( + 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( + 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( + input = defaultInput(app = app), + ) + assertEquals(InstallButtonAction.NoOp, state.actionIntent) + assertEquals(StatusTag.Unknown, state.statusTag) + } +} 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 0000000000000000000000000000000000000000..1f37a9cfa8f45d95138eeb8fe6cd11a136653664 --- /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 0000000000000000000000000000000000000000..c4d9997411859d4fd01313a8f3655a2f050777ef --- /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 7e4e7aa1b5fdd8745d764e88597305bfce751653..37e54d24ce685272700fdc5ae4e200ef7259174f 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 @@ -20,7 +20,6 @@ package foundation.e.apps.ui.search.v2 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 @@ -29,14 +28,24 @@ 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 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.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 @@ -65,7 +74,14 @@ 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 fetchSearchSuggestionsUseCase: FetchSearchSuggestionsUseCase + private lateinit var prepareSearchSubmissionUseCase: PrepareSearchSubmissionUseCase private lateinit var stores: Stores + private lateinit var installStatusStream: InstallStatusStream + private lateinit var installStatusReconciler: InstallStatusReconciler private var playStoreSelected = true private var openSourceSelected = true private var pwaSelected = false @@ -77,6 +93,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) every { preference.isPlayStoreSelected() } answers { playStoreSelected } every { preference.isOpenSourceSelected() } answers { openSourceSelected } @@ -87,6 +111,22 @@ 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) + } + every { playStoreAppMapper.map(any()) } answers { + Application( + _id = (args[0] as App).packageName, + package_name = (args[0] as App).packageName, + ) + } buildViewModel() } @@ -278,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 @@ -333,7 +392,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() @@ -353,7 +417,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 +436,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()) } @@ -428,6 +492,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) @@ -439,6 +534,78 @@ 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() @@ -461,19 +628,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) @@ -482,13 +636,19 @@ class SearchViewModelV2Test { private fun buildViewModel() { stores = buildStores() + fetchSearchSuggestionsUseCase = FetchSearchSuggestionsUseCase(suggestionSource, preference) + prepareSearchSubmissionUseCase = PrepareSearchSubmissionUseCase(stores) viewModel = SearchViewModelV2( - suggestionSource, preference, - searchPagingRepository, - playStorePagingRepository, - stores + cleanApkSearchPagingUseCase, + playStoreSearchPagingUseCase, + fetchSearchSuggestionsUseCase, + prepareSearchSubmissionUseCase, + stores, + installStatusStream, + installStatusReconciler, ) + runStoreUpdates() } private class NoopListCallback : ListUpdateCallback { @@ -501,14 +661,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,