diff --git a/app/build.gradle b/app/build.gradle index d71def50182fc017ae60fd19f11cf2458c22f91f..aa3fad36ca9db4fb50c5854c9b981a7344e6557c 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -214,6 +214,7 @@ dependencies { implementation(libs.navigation.fragment.ktx) implementation(libs.navigation.ui.ktx) implementation(libs.activity.ktx) + implementation(libs.paging.runtime.ktx) // Material Design implementation(libs.material) @@ -304,6 +305,7 @@ dependencies { implementation libs.activity.compose implementation libs.lifecycle.viewmodel.compose implementation libs.runtime.livedata + implementation libs.paging.compose // Android Studio Preview support for Compose implementation libs.compose.ui.tooling.preview 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 5e6d12cb27e26a58bd6bd4b36794be89484747eb..6b7ae14eb439ed6d94034d79ebb222cdd7a43de1 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 @@ -28,10 +28,20 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick +import androidx.paging.LoadState +import androidx.paging.LoadStates +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.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith import foundation.e.apps.R import foundation.e.apps.data.application.data.Application import foundation.e.apps.data.application.data.Ratings @@ -39,10 +49,7 @@ import foundation.e.apps.data.enums.Source import foundation.e.apps.data.enums.Status import foundation.e.apps.ui.compose.theme.AppTheme import foundation.e.apps.ui.search.v2.SearchTabType -import org.junit.Assert.assertTrue -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith +import java.util.Locale @RunWith(AndroidJUnit4::class) class SearchResultsContentTest { @@ -51,62 +58,55 @@ class SearchResultsContentTest { @Test fun emptyTabs_renderNothing() { - composeRule.setContent { - AppTheme(darkTheme = false) { - Surface(color = MaterialTheme.colorScheme.background) { - SearchResultsContent( - tabs = emptyList(), - selectedTab = SearchTabType.COMMON_APPS, - resultsByTab = mapOf( - SearchTabType.COMMON_APPS to listOf(sampleApp("Hidden App")) - ), - onTabSelect = {}, - ) - } - } - } + val noAppsText = composeRule.activity.getString(R.string.no_apps_found) - composeRule.onAllNodesWithText("Hidden App") - .assertCountEquals(0) + renderSearchResults( + tabs = emptyList(), + selectedTab = SearchTabType.COMMON_APPS, + fossPagingData = PagingData.empty(), + ) + + composeRule.onAllNodesWithText(noAppsText).assertCountEquals(0) } @Test fun selectedTabOutsideTabs_renderNothing() { - composeRule.setContent { - AppTheme(darkTheme = false) { - Surface(color = MaterialTheme.colorScheme.background) { - SearchResultsContent( - tabs = listOf(SearchTabType.OPEN_SOURCE), - selectedTab = SearchTabType.COMMON_APPS, - resultsByTab = mapOf( - SearchTabType.COMMON_APPS to listOf(sampleApp("Missing Tab App")) - ), - onTabSelect = {}, - ) - } - } - } + val noAppsText = composeRule.activity.getString(R.string.no_apps_found) + + renderSearchResults( + tabs = listOf(SearchTabType.OPEN_SOURCE), + selectedTab = SearchTabType.COMMON_APPS, + fossPagingData = PagingData.from(listOf(sampleApp("Hidden App"))), + ) - composeRule.onAllNodesWithText("Missing Tab App") - .assertCountEquals(0) + composeRule.onAllNodesWithText(noAppsText).assertCountEquals(0) } @Test fun tabSelection_updatesDisplayedResults() { val selectedTabs = mutableListOf() val openSourceLabel = composeRule.activity.getString(R.string.search_tab_open_source) + val pwaLabel = composeRule.activity.getString(R.string.search_tab_web_apps) composeRule.setContent { - var selectedTab by remember { mutableStateOf(SearchTabType.COMMON_APPS) } + var selectedTab by remember { mutableStateOf(SearchTabType.OPEN_SOURCE) } + val fossItems = remember { + flowOf(pagingData(listOf(sampleApp("Open App")))) + }.collectAsLazyPagingItems() + val pwaItems = remember { + flowOf(pagingData(listOf(sampleApp("PWA App")))) + }.collectAsLazyPagingItems() + AppTheme(darkTheme = false) { Surface(color = MaterialTheme.colorScheme.background) { SearchResultsContent( - tabs = listOf(SearchTabType.COMMON_APPS, SearchTabType.OPEN_SOURCE), + tabs = listOf(SearchTabType.OPEN_SOURCE, SearchTabType.PWA), selectedTab = selectedTab, - resultsByTab = mapOf( - SearchTabType.COMMON_APPS to listOf(sampleApp("Common App")), - SearchTabType.OPEN_SOURCE to listOf(sampleApp("Open App")), - ), + fossItems = fossItems, + pwaItems = pwaItems, + searchVersion = 0, + getScrollPosition = { null }, + onScrollPositionChange = { _, _, _ -> }, onTabSelect = { tab -> selectedTab = tab selectedTabs.add(tab) @@ -116,17 +116,14 @@ class SearchResultsContentTest { } } - composeRule.onNodeWithText("Common App") - .assertIsDisplayed() - composeRule.onNodeWithText(openSourceLabel) - .performClick() - + composeRule.onNodeWithText("Open App").assertIsDisplayed() + composeRule.onNodeWithText(openSourceLabel).assertIsDisplayed() + composeRule.onNodeWithText(pwaLabel).performClick() composeRule.waitForIdle() - composeRule.onNodeWithText("Open App") - .assertIsDisplayed() + composeRule.onNodeWithText("PWA App").assertIsDisplayed() composeRule.runOnIdle { - assertTrue(selectedTabs.contains(SearchTabType.OPEN_SOURCE)) + assertTrue(selectedTabs.contains(SearchTabType.PWA)) } } @@ -134,58 +131,167 @@ class SearchResultsContentTest { fun applicationMapping_setsAuthorRatingAndPrimaryAction() { val notAvailable = composeRule.activity.getString(R.string.not_available) val openLabel = composeRule.activity.getString(R.string.open) + val expectedRating = String.format(Locale.getDefault(), "%.1f", 4.4) + val unexpectedRating = String.format(Locale.getDefault(), "%.1f", 4.9) + renderSearchResults( + tabs = listOf(SearchTabType.OPEN_SOURCE), + selectedTab = SearchTabType.OPEN_SOURCE, + fossPagingData = pagingData( + listOf( + Application( + name = "Rated App", + author = "", + package_name = "com.example.rated", + source = Source.PLAY_STORE, + ratings = Ratings(usageQualityScore = 4.4), + status = Status.INSTALLED, + ), + Application( + name = "Unrated App", + author = "Team", + package_name = "com.example.unrated", + source = Source.PLAY_STORE, + ratings = Ratings(usageQualityScore = -1.0), + status = Status.UPDATABLE, + ), + Application( + name = "Foss App", + author = "Foss Team", + package_name = "org.example.foss", + source = Source.OPEN_SOURCE, + ratings = Ratings(usageQualityScore = 4.9), + status = Status.UPDATABLE, + ), + ) + ) + ) + + composeRule.onNodeWithText("com.example.rated").assertIsDisplayed() + composeRule.onNodeWithText(expectedRating).assertIsDisplayed() + composeRule.onNodeWithText(openLabel).assertIsDisplayed() + composeRule.onNodeWithText(notAvailable).assertIsDisplayed() + composeRule.onAllNodesWithText(unexpectedRating).assertCountEquals(0) + } + + @Test + fun refreshLoading_showsShimmer() { + val pagingData = PagingData.empty( + sourceLoadStates = loadStates(refresh = LoadState.Loading) + ) + + renderSearchResults( + tabs = listOf(SearchTabType.OPEN_SOURCE), + selectedTab = SearchTabType.OPEN_SOURCE, + fossPagingData = pagingData, + ) + + composeRule.onNodeWithTag(SearchResultsContentTestTags.REFRESH_LOADER) + .assertIsDisplayed() + composeRule.onAllNodesWithText("Open App").assertCountEquals(0) + } + + @Test + fun refreshError_showsRetry() { + val pagingData = PagingData.empty( + sourceLoadStates = loadStates(refresh = LoadState.Error(RuntimeException("boom"))) + ) + + renderSearchResults( + tabs = listOf(SearchTabType.OPEN_SOURCE), + selectedTab = SearchTabType.OPEN_SOURCE, + fossPagingData = pagingData, + ) + + 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( + sourceLoadStates = loadStates( + refresh = LoadState.NotLoading(endOfPaginationReached = true) + ) + ) + val noAppsText = composeRule.activity.getString(R.string.no_apps_found) + + renderSearchResults( + tabs = listOf(SearchTabType.OPEN_SOURCE), + selectedTab = SearchTabType.OPEN_SOURCE, + fossPagingData = pagingData, + ) + + composeRule.onNodeWithText(noAppsText).assertIsDisplayed() + } + + @Test + fun appendLoading_showsBottomSpinner() { + val pagingData = PagingData.from( + listOf(sampleApp("Open App")), + sourceLoadStates = loadStates( + refresh = LoadState.NotLoading(endOfPaginationReached = false), + append = LoadState.Loading + ) + ) + + renderSearchResults( + tabs = listOf(SearchTabType.OPEN_SOURCE), + selectedTab = SearchTabType.OPEN_SOURCE, + fossPagingData = pagingData, + ) + + composeRule.onNodeWithText("Open App").assertIsDisplayed() + composeRule.onNodeWithTag(SearchResultsContentTestTags.APPEND_LOADER) + .assertIsDisplayed() + } + + private fun renderSearchResults( + tabs: List, + selectedTab: SearchTabType, + fossPagingData: PagingData, + pwaPagingData: PagingData? = null, + searchVersion: Int = 0, + ) { composeRule.setContent { + val fossItems = remember { flowOf(fossPagingData) }.collectAsLazyPagingItems() + val pwaItems = pwaPagingData?.let { + remember(it) { flowOf(it) }.collectAsLazyPagingItems() + } + AppTheme(darkTheme = false) { Surface(color = MaterialTheme.colorScheme.background) { SearchResultsContent( - tabs = listOf(SearchTabType.COMMON_APPS), - selectedTab = SearchTabType.COMMON_APPS, - resultsByTab = mapOf( - SearchTabType.COMMON_APPS to listOf( - Application( - name = "Rated App", - author = "", - package_name = "com.example.rated", - source = Source.PLAY_STORE, - ratings = Ratings(usageQualityScore = 4.4), - status = Status.INSTALLED, - ), - Application( - name = "Unrated App", - author = "Team", - package_name = "com.example.unrated", - source = Source.PLAY_STORE, - ratings = Ratings(usageQualityScore = -1.0), - status = Status.UPDATABLE, - ), - Application( - name = "Foss App", - author = "Foss Team", - package_name = "org.example.foss", - source = Source.OPEN_SOURCE, - ratings = Ratings(usageQualityScore = 4.9), - status = Status.UPDATABLE, - ), - ) - ), + tabs = tabs, + selectedTab = selectedTab, + fossItems = fossItems, + pwaItems = pwaItems, + searchVersion = searchVersion, + getScrollPosition = { null }, + onScrollPositionChange = { _, _, _ -> }, onTabSelect = {}, ) } } } - - composeRule.onNodeWithText("com.example.rated") - .assertIsDisplayed() - composeRule.onNodeWithText("4.4") - .assertIsDisplayed() - composeRule.onNodeWithText(openLabel) - .assertIsDisplayed() - composeRule.onNodeWithText(notAvailable) - .assertIsDisplayed() - composeRule.onAllNodesWithText("4.9") - .assertCountEquals(0) } + private fun loadStates( + refresh: LoadState, + append: LoadState = LoadState.NotLoading(endOfPaginationReached = true), + prepend: LoadState = LoadState.NotLoading(endOfPaginationReached = true), + ) = LoadStates(refresh = refresh, prepend = prepend, append = append) + private fun sampleApp(name: String) = Application(name = name) + + private fun pagingData(apps: List) = PagingData.from( + apps, + sourceLoadStates = loadStates( + refresh = LoadState.NotLoading(endOfPaginationReached = false) + ) + ) } diff --git a/app/src/main/java/foundation/e/apps/data/application/data/Application.kt b/app/src/main/java/foundation/e/apps/data/application/data/Application.kt index aa5767318247da566778e070b8590ff53b278667..13cedfb0283ba4e6656accdab9ed634670a35077 100644 --- a/app/src/main/java/foundation/e/apps/data/application/data/Application.kt +++ b/app/src/main/java/foundation/e/apps/data/application/data/Application.kt @@ -23,6 +23,7 @@ import androidx.core.net.toUri import com.aurora.gplayapi.Constants.Restriction import com.aurora.gplayapi.data.models.ContentRating import com.google.gson.annotations.SerializedName +import foundation.e.apps.data.cleanapk.CleanApkRetrofit import foundation.e.apps.data.enums.FilterLevel import foundation.e.apps.data.enums.Source import foundation.e.apps.data.enums.Status @@ -99,6 +100,23 @@ data class Application( val antiFeatures: List> = emptyList(), var isSystemApp: Boolean = false, ) { + val iconUrl: String? + get() { + if (icon_image_path.isBlank()) { + return null + } + return when (source) { + Source.OPEN_SOURCE, Source.PWA -> { + if (icon_image_path.startsWith("http")) { + icon_image_path + } else { + CleanApkRetrofit.ASSET_URL + icon_image_path + } + } + Source.SYSTEM_APP, Source.PLAY_STORE -> icon_image_path + } + } + fun updateType() { this.type = if (this.is_pwa) PWA else NATIVE } diff --git a/app/src/main/java/foundation/e/apps/data/cleanapk/CleanApkRetrofit.kt b/app/src/main/java/foundation/e/apps/data/cleanapk/CleanApkRetrofit.kt index c778d4b5b0ac949104f7bca65922e8d56fc288ab..281a67d0238fe93b255a4e70e3dcc57f3b95a51a 100644 --- a/app/src/main/java/foundation/e/apps/data/cleanapk/CleanApkRetrofit.kt +++ b/app/src/main/java/foundation/e/apps/data/cleanapk/CleanApkRetrofit.kt @@ -59,30 +59,30 @@ interface CleanApkRetrofit { @Query("type") type: String? = null, ): Response - @Suppress("LongParameterList") - // Retrofit endpoint mirrors CleanAPK query parameters; keeping them explicit preserves the API contract - @GET("apps?action=search") - suspend fun searchApps( - @Query("keyword") keyword: String, - @Query("source") source: String = APP_SOURCE_FOSS, - @Query("type") type: String = APP_TYPE_ANY, - @Query("nres") nres: Int = 20, - @Query("page") page: Int = 1, - @Query("by") by: String? = null, - @Query("architectures") architectures: List? = null, - ): Response - - @Suppress("LongParameterList") - // Endpoint requires explicit query parts; grouping them would hide required inputs - @GET("apps?action=list_apps") - suspend fun listApps( - @Query("category") category: String, - @Query("source") source: String = APP_SOURCE_FOSS, - @Query("type") type: String = APP_TYPE_ANY, - @Query("nres") nres: Int = 20, - @Query("page") page: Int = 1, - @Query("architectures") architectures: List? = null, - ): Response + @Suppress("LongParameterList") + // Retrofit endpoint mirrors CleanAPK query parameters; keeping them explicit preserves the API contract + @GET("apps?action=search") + suspend fun searchApps( + @Query("keyword") keyword: String, + @Query("source") source: String = APP_SOURCE_FOSS, + @Query("type") type: String = APP_TYPE_ANY, + @Query("nres") pageSize: Int = 20, + @Query("page") page: Int = 1, + @Query("by") by: String? = null, + @Query("architectures") architectures: List? = null, + ): Response + + @Suppress("LongParameterList") + // Endpoint requires explicit query parts; grouping them would hide required inputs + @GET("apps?action=list_apps") + suspend fun listApps( + @Query("category") category: String, + @Query("source") source: String = APP_SOURCE_FOSS, + @Query("type") type: String = APP_TYPE_ANY, + @Query("nres") nres: Int = 20, + @Query("page") page: Int = 1, + @Query("architectures") architectures: List? = null, + ): Response @GET("apps?action=list_apps") suspend fun checkAvailablePackages( diff --git a/app/src/main/java/foundation/e/apps/data/cleanapk/CleanApkSearchHelper.kt b/app/src/main/java/foundation/e/apps/data/cleanapk/CleanApkSearchHelper.kt index 3cbfb4e01a8fd7f183f088be4f10adc8ad127a7b..97de5c53edf186c35f678f1b369bcf8562214792 100644 --- a/app/src/main/java/foundation/e/apps/data/cleanapk/CleanApkSearchHelper.kt +++ b/app/src/main/java/foundation/e/apps/data/cleanapk/CleanApkSearchHelper.kt @@ -19,6 +19,7 @@ package foundation.e.apps.data.cleanapk import foundation.e.apps.data.application.data.Application +import foundation.e.apps.data.cleanapk.data.search.Search import foundation.e.apps.data.cleanapk.repositories.NUMBER_OF_ITEMS import foundation.e.apps.data.cleanapk.repositories.NUMBER_OF_PAGES import foundation.e.apps.data.enums.Source @@ -36,16 +37,47 @@ class CleanApkSearchHelper @Inject constructor( appType: String ): List { return withContext(Dispatchers.IO) { - val searchResult = cleanApkRetrofit.searchApps( + getSearchResultPage( + keyword = keyword, + appSource = appSource, + appType = appType, + page = NUMBER_OF_PAGES, + pageSize = NUMBER_OF_ITEMS, + ).apps + } + } + + suspend fun getSearchResultPage( + keyword: String, + appSource: String, + appType: String, + page: Int, + pageSize: Int, + ): Search { + return withContext(Dispatchers.IO) { + val response = cleanApkRetrofit.searchApps( keyword = keyword, source = appSource, type = appType, - nres = NUMBER_OF_ITEMS, - page = NUMBER_OF_PAGES, + pageSize = pageSize, + page = page, architectures = SystemInfoProvider.getSupportedArchitectureList(), ) - searchResult.body()?.apps.orEmpty() - .map { it.apply { source = mapSource(it) } } + + check(response.isSuccessful) { + "CleanAPK search failed: HTTP ${response.code()}" + } + + val body = checkNotNull(response.body()) { + "CleanAPK search failed: empty body" + } + + check(body.success) { + "CleanAPK search failed: success=false" + } + + body.apps.forEach { app -> app.source = mapSource(app) } + body } } diff --git a/app/src/main/java/foundation/e/apps/data/cleanapk/data/search/Search.kt b/app/src/main/java/foundation/e/apps/data/cleanapk/data/search/Search.kt index 3bec2357d3e1b36f81e4573afce2ab21937b5a69..be66b5eac968fc33ea82a7ee2cbcea2d99af9eb6 100644 --- a/app/src/main/java/foundation/e/apps/data/cleanapk/data/search/Search.kt +++ b/app/src/main/java/foundation/e/apps/data/cleanapk/data/search/Search.kt @@ -1,6 +1,5 @@ /* - * Apps Quickly and easily install Android apps onto your device! - * Copyright (C) 2021 E FOUNDATION + * Copyright (C) 2021-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 @@ -14,14 +13,17 @@ * * You should have received a copy of the GNU General Public License * along with this program. If not, see . + * */ package foundation.e.apps.data.cleanapk.data.search +import com.google.gson.annotations.SerializedName import foundation.e.apps.data.application.data.Application data class Search( val apps: List = emptyList(), val numberOfResults: Int = -1, + @SerializedName(value = "pages") val numberOfPages: Int = 0, val success: Boolean = false ) diff --git a/app/src/main/java/foundation/e/apps/data/search/CleanApkSearchPagingRepository.kt b/app/src/main/java/foundation/e/apps/data/search/CleanApkSearchPagingRepository.kt new file mode 100644 index 0000000000000000000000000000000000000000..d6bfb54e5f82696a02edd2cf264ac02bf54ea3b9 --- /dev/null +++ b/app/src/main/java/foundation/e/apps/data/search/CleanApkSearchPagingRepository.kt @@ -0,0 +1,48 @@ +/* + * 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 androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.PagingData +import foundation.e.apps.data.application.data.Application +import foundation.e.apps.data.cleanapk.CleanApkSearchHelper +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject + +class CleanApkSearchPagingRepository @Inject constructor( + private val cleanApkSearchHelper: CleanApkSearchHelper, +) : SearchPagingRepository { + + override fun cleanApkSearch(params: CleanApkSearchParams): Flow> { + return Pager( + config = PagingConfig( + pageSize = params.pageSize, + enablePlaceholders = false, + prefetchDistance = 2 + ), + pagingSourceFactory = { + CleanApkSearchPagingSource( + cleanApkSearchHelper = cleanApkSearchHelper, + params = params + ) + } + ).flow + } +} diff --git a/app/src/main/java/foundation/e/apps/data/search/CleanApkSearchPagingSource.kt b/app/src/main/java/foundation/e/apps/data/search/CleanApkSearchPagingSource.kt new file mode 100644 index 0000000000000000000000000000000000000000..e2c4c7d71483a2e8519252a36ad968d1fcd2a7cd --- /dev/null +++ b/app/src/main/java/foundation/e/apps/data/search/CleanApkSearchPagingSource.kt @@ -0,0 +1,76 @@ +/* + * 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 androidx.paging.PagingSource +import androidx.paging.PagingState +import foundation.e.apps.data.application.data.Application +import foundation.e.apps.data.cleanapk.CleanApkSearchHelper +import retrofit2.HttpException +import java.io.IOException + +private const val INITIAL_PAGE = 1 + +class CleanApkSearchPagingSource( + private val cleanApkSearchHelper: CleanApkSearchHelper, + private val params: CleanApkSearchParams, +) : PagingSource() { + + override fun getRefreshKey(state: PagingState): Int? { + val anchor = state.anchorPosition ?: return null + val anchorPage = state.closestPageToPosition(anchor) + val prev = anchorPage?.prevKey + val next = anchorPage?.nextKey + return when { + prev != null -> prev + 1 + next != null -> next - 1 + else -> null + } + } + + override suspend fun load(params: LoadParams): LoadResult { + return try { + val page = params.key ?: INITIAL_PAGE + val response = cleanApkSearchHelper.getSearchResultPage( + keyword = this.params.keyword, + appSource = this.params.appSource, + appType = this.params.appType, + page = page, + pageSize = this.params.pageSize, + ) + + val totalPages = response.numberOfPages + + val nextKey = if (page < totalPages) page + 1 else null + val prevKey = if (page > INITIAL_PAGE) page - 1 else null + + LoadResult.Page( + data = response.apps, + prevKey = prevKey, + nextKey = nextKey + ) + } catch (exception: IOException) { + LoadResult.Error(exception) + } catch (exception: HttpException) { + LoadResult.Error(exception) + } catch (exception: IllegalStateException) { + LoadResult.Error(exception) + } + } +} diff --git a/app/src/main/java/foundation/e/apps/data/search/CleanApkSearchParams.kt b/app/src/main/java/foundation/e/apps/data/search/CleanApkSearchParams.kt new file mode 100644 index 0000000000000000000000000000000000000000..3422dbeb5283ca4cbf5e421624264630f4cee20a --- /dev/null +++ b/app/src/main/java/foundation/e/apps/data/search/CleanApkSearchParams.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.data.search + +data class CleanApkSearchParams( + val keyword: String, + val appSource: String, + val appType: String, + val pageSize: Int = 20, +) diff --git a/app/src/main/java/foundation/e/apps/data/search/PlayStorePagingRepository.kt b/app/src/main/java/foundation/e/apps/data/search/PlayStorePagingRepository.kt new file mode 100644 index 0000000000000000000000000000000000000000..aefa7c6b6190d258ab35ebdc314de254981a4964 --- /dev/null +++ b/app/src/main/java/foundation/e/apps/data/search/PlayStorePagingRepository.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.data.search + +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.PagingData +import com.aurora.gplayapi.data.models.App +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class PlayStorePagingRepository @Inject constructor( + private val playStoreWebSearch: PlayStoreWebSearch, +) { + + fun playStoreSearch(query: String, pageSize: Int): Flow> { + return Pager( + config = buildPagingConfig(pageSize), + pagingSourceFactory = buildPagingSourceFactory(query), + ).flow + } + + internal fun buildPagingConfig(pageSize: Int): PagingConfig { + return PagingConfig( + pageSize = pageSize, + enablePlaceholders = false, + prefetchDistance = PREFETCH_DISTANCE + ) + } + + internal fun buildPagingSourceFactory(query: String): () -> PlayStorePagingSource = + { createPagingSource(query) } + + internal fun createPagingSource(query: String): PlayStorePagingSource = + PlayStorePagingSource( + query = query, + playStoreWebSearch = playStoreWebSearch, + ) + + private companion object { + private const val PREFETCH_DISTANCE = 2 + } +} diff --git a/app/src/main/java/foundation/e/apps/data/search/PlayStorePagingSource.kt b/app/src/main/java/foundation/e/apps/data/search/PlayStorePagingSource.kt new file mode 100644 index 0000000000000000000000000000000000000000..a8f5d47c918f1f3f195b1943775918ce56ec023c --- /dev/null +++ b/app/src/main/java/foundation/e/apps/data/search/PlayStorePagingSource.kt @@ -0,0 +1,134 @@ +/* + * 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 androidx.paging.PagingSource +import androidx.paging.PagingState +import com.aurora.gplayapi.data.models.App +import com.aurora.gplayapi.data.models.StreamCluster +import foundation.e.apps.data.playstore.utils.GplayHttpRequestException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.IOException + +private const val INITIAL_PAGE = 1 + +class PlayStorePagingSource( + private val query: String, + private val playStoreWebSearch: PlayStoreWebSearch, +) : PagingSource() { + + private var nextBundleUrl: String? = null + private val nextStreamUrls = mutableSetOf() + + override fun getRefreshKey(state: PagingState): Int? { + val anchor = state.anchorPosition + val anchorPage = anchor?.let { state.closestPageToPosition(it) } + val prev = anchorPage?.prevKey + val next = anchorPage?.nextKey + return when { + prev != null -> prev + 1 + next != null -> next - 1 + else -> null + } + } + + override suspend fun load(params: LoadParams): LoadResult { + val page = params.key ?: INITIAL_PAGE + return try { + val data: List = when (page) { + INITIAL_PAGE -> loadFirstPage() + else -> loadNextPage() + } + + val nextKey = if (data.isEmpty()) { + null + } else { + page + 1 + } + val prevKey = if (page == INITIAL_PAGE) { + null + } else { + page - 1 + } + + val loadResult = LoadResult.Page( + data = data, + prevKey = prevKey, + nextKey = nextKey + ) + loadResult + } catch (exception: GplayHttpRequestException) { + LoadResult.Error(exception) + } catch (exception: IOException) { + LoadResult.Error(exception) + } catch (exception: IllegalStateException) { + LoadResult.Error(exception) + } + } + + private suspend fun loadFirstPage(): List = withContext(Dispatchers.IO) { + val bundle = playStoreWebSearch.searchResults(query) + nextBundleUrl = bundle.streamNextPageUrl.takeIf { it.isNotBlank() } + + if (!bundle.hasCluster()) { + return@withContext emptyList() + } + + bundle.streamClusters.values.collectApplications() + } + + private suspend fun loadNextPage(): List = withContext(Dispatchers.IO) { + return@withContext when { + nextStreamUrls.isNotEmpty() -> { + val pendingStreamUrls = nextStreamUrls.toList() + nextStreamUrls.clear() + + pendingStreamUrls.flatMap { streamUrl -> + val cluster = playStoreWebSearch.nextStreamCluster(query, streamUrl) + listOf(cluster).collectApplications() + } + } + + !nextBundleUrl.isNullOrBlank() -> { + val bundle = playStoreWebSearch.nextStreamBundle(query, nextBundleUrl!!) + nextBundleUrl = bundle.streamNextPageUrl.takeIf { it.isNotBlank() } + + bundle.streamClusters.values.collectApplications() + } + + else -> emptyList() + } + } + + private fun Collection.collectApplications(): List { + val apps = mutableListOf() + + this.forEach { cluster -> + if (cluster.hasNext()) { + nextStreamUrls.add(cluster.clusterNextPageUrl) + } + apps.addAll(cluster.clusterAppList) + } + + // Deduplicate by package name to avoid duplicate rows when the API returns overlapping clusters. + return apps + .distinctBy { app -> app.packageName } + } +} diff --git a/app/src/main/java/foundation/e/apps/data/search/PlayStoreWebSearch.kt b/app/src/main/java/foundation/e/apps/data/search/PlayStoreWebSearch.kt new file mode 100644 index 0000000000000000000000000000000000000000..672fcb5d2fd71dc8b3343a0975cd5a36306e3cee --- /dev/null +++ b/app/src/main/java/foundation/e/apps/data/search/PlayStoreWebSearch.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.data.search + +import com.aurora.gplayapi.data.models.StreamBundle +import com.aurora.gplayapi.data.models.StreamCluster + +interface PlayStoreWebSearch { + suspend fun searchResults(query: String): StreamBundle + + suspend fun nextStreamBundle(query: String, nextUrl: String): StreamBundle + + suspend fun nextStreamCluster(query: String, nextUrl: String): StreamCluster +} diff --git a/app/src/main/java/foundation/e/apps/data/search/PlayStoreWebSearchImpl.kt b/app/src/main/java/foundation/e/apps/data/search/PlayStoreWebSearchImpl.kt new file mode 100644 index 0000000000000000000000000000000000000000..ffdceb68b0af8267a2ce435a4fe53ed313725eef --- /dev/null +++ b/app/src/main/java/foundation/e/apps/data/search/PlayStoreWebSearchImpl.kt @@ -0,0 +1,42 @@ +/* + * 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 com.aurora.gplayapi.data.models.StreamBundle +import com.aurora.gplayapi.data.models.StreamCluster +import com.aurora.gplayapi.helpers.web.WebSearchHelper +import foundation.e.apps.data.playstore.utils.GPlayHttpClient +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class PlayStoreWebSearchImpl @Inject constructor( + gPlayHttpClient: GPlayHttpClient, +) : PlayStoreWebSearch { + private val webSearchHelper = WebSearchHelper().using(gPlayHttpClient) + + override suspend fun searchResults(query: String): StreamBundle = + webSearchHelper.searchResults(query) + + override suspend fun nextStreamBundle(query: String, nextUrl: String): StreamBundle = + webSearchHelper.nextStreamBundle(query, nextUrl) + + override suspend fun nextStreamCluster(query: String, nextUrl: String): StreamCluster = + webSearchHelper.nextStreamCluster(query, nextUrl) +} diff --git a/app/src/main/java/foundation/e/apps/data/search/SearchPagingRepository.kt b/app/src/main/java/foundation/e/apps/data/search/SearchPagingRepository.kt new file mode 100644 index 0000000000000000000000000000000000000000..970eb55a7e23f1d830f9f8fad41fa7b9e2e33be3 --- /dev/null +++ b/app/src/main/java/foundation/e/apps/data/search/SearchPagingRepository.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.data.search + +import androidx.paging.PagingData +import foundation.e.apps.data.application.data.Application +import kotlinx.coroutines.flow.Flow + +interface SearchPagingRepository { + fun cleanApkSearch(params: CleanApkSearchParams): Flow> +} diff --git a/app/src/main/java/foundation/e/apps/di/SearchPagingModule.kt b/app/src/main/java/foundation/e/apps/di/SearchPagingModule.kt new file mode 100644 index 0000000000000000000000000000000000000000..54d3f25190444a13b562eb52904c2877cd5b25b9 --- /dev/null +++ b/app/src/main/java/foundation/e/apps/di/SearchPagingModule.kt @@ -0,0 +1,45 @@ +/* + * 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.di + +import dagger.Binds +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.PlayStoreWebSearch +import foundation.e.apps.data.search.PlayStoreWebSearchImpl +import foundation.e.apps.data.search.SearchPagingRepository +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +abstract class SearchPagingModule { + @Binds + @Singleton + abstract fun bindSearchPagingRepository( + impl: CleanApkSearchPagingRepository + ): SearchPagingRepository + + @Binds + @Singleton + abstract fun bindPlayStoreWebSearch( + impl: PlayStoreWebSearchImpl + ): PlayStoreWebSearch +} diff --git a/app/src/main/java/foundation/e/apps/ui/application/ApplicationFragment.kt b/app/src/main/java/foundation/e/apps/ui/application/ApplicationFragment.kt index beb105b563973f5485e0d9d929f5ea4b8832dbf3..f551b6af90dcf7d7483c0a987ae8cdf028b52afa 100644 --- a/app/src/main/java/foundation/e/apps/ui/application/ApplicationFragment.kt +++ b/app/src/main/java/foundation/e/apps/ui/application/ApplicationFragment.kt @@ -54,7 +54,6 @@ import dagger.hilt.android.AndroidEntryPoint import foundation.e.apps.R import foundation.e.apps.data.application.data.Application import foundation.e.apps.data.application.data.shareUri -import foundation.e.apps.data.cleanapk.CleanApkRetrofit import foundation.e.apps.data.enums.ResultStatus import foundation.e.apps.data.enums.Source import foundation.e.apps.data.enums.Status @@ -449,10 +448,8 @@ class ApplicationFragment : TimeoutFragment(R.layout.fragment_application) { if (source == Source.OPEN_SOURCE || source == Source.PWA) { sourceTag.visibility = View.VISIBLE sourceTag.text = it.source.toString() - appIcon.load(CleanApkRetrofit.ASSET_URL + it.icon_image_path) - } else { - appIcon.load(it.icon_image_path) } + appIcon.load(it.iconUrl) } updateAntiFeaturesUi(it) diff --git a/app/src/main/java/foundation/e/apps/ui/applicationlist/ApplicationListRVAdapter.kt b/app/src/main/java/foundation/e/apps/ui/applicationlist/ApplicationListRVAdapter.kt index bb38a19bb42357c312ed1a1e548f4d7bd2a0edfc..812071cd0234d2b05c280d34e25453f6dc7fdb92 100644 --- a/app/src/main/java/foundation/e/apps/ui/applicationlist/ApplicationListRVAdapter.kt +++ b/app/src/main/java/foundation/e/apps/ui/applicationlist/ApplicationListRVAdapter.kt @@ -42,7 +42,6 @@ import com.google.android.material.snackbar.Snackbar import foundation.e.apps.R import foundation.e.apps.data.application.ApplicationInstaller import foundation.e.apps.data.application.data.Application -import foundation.e.apps.data.cleanapk.CleanApkRetrofit import foundation.e.apps.data.enums.Source import foundation.e.apps.data.enums.Status import foundation.e.apps.data.enums.User @@ -173,18 +172,10 @@ class ApplicationListRVAdapter( shimmerDrawable: ShimmerDrawable ) { when (searchApp.source) { - Source.PLAY_STORE -> { - appIcon.load(searchApp.icon_image_path) { - placeholder(shimmerDrawable) - } - } - Source.PWA -> { - appIcon.load(CleanApkRetrofit.ASSET_URL + searchApp.icon_image_path) { - placeholder(shimmerDrawable) - } - } + Source.PLAY_STORE, + Source.PWA, Source.OPEN_SOURCE -> { - appIcon.load(CleanApkRetrofit.ASSET_URL + searchApp.icon_image_path) { + appIcon.load(searchApp.iconUrl) { placeholder(shimmerDrawable) } } diff --git a/app/src/main/java/foundation/e/apps/ui/compose/components/SearchInitialState.kt b/app/src/main/java/foundation/e/apps/ui/compose/components/SearchInitialState.kt new file mode 100644 index 0000000000000000000000000000000000000000..be1e7a5361bcf53f02e3c8ec7ed2311425117ff4 --- /dev/null +++ b/app/src/main/java/foundation/e/apps/ui/compose/components/SearchInitialState.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 + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Search +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +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 SearchInitialState(modifier: Modifier = Modifier) { + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon( + imageVector = Icons.Outlined.Search, + contentDescription = null, + tint = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.45f), + modifier = Modifier + .padding(bottom = 4.dp) + .size(72.dp), + ) + Text( + text = stringResource(id = R.string.search_hint), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.72f), + ) + } + } +} + +@Preview(showBackground = false) +@Composable +private fun SearchInitialStatePreview() { + AppTheme(darkTheme = true) { + SearchInitialState() + } +} diff --git a/app/src/main/java/foundation/e/apps/ui/compose/components/SearchPlaceholder.kt b/app/src/main/java/foundation/e/apps/ui/compose/components/SearchPlaceholder.kt index 696936035fe67d36bd79e7ee78271ecf7dbdbb95..60c415be545545916342b80fa9cae4f775f3bfe4 100644 --- a/app/src/main/java/foundation/e/apps/ui/compose/components/SearchPlaceholder.kt +++ b/app/src/main/java/foundation/e/apps/ui/compose/components/SearchPlaceholder.kt @@ -18,23 +18,25 @@ package foundation.e.apps.ui.compose.components +import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.Search -import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import foundation.e.apps.R import foundation.e.apps.ui.compose.theme.AppTheme @@ -49,18 +51,21 @@ fun SearchPlaceholder(modifier: Modifier = Modifier) { horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(12.dp), ) { - Icon( - imageVector = Icons.Outlined.Search, + Image( + painter = painterResource(id = R.drawable.ic_error_circular), contentDescription = stringResource(id = R.string.menu_search), - tint = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.45f), + contentScale = ContentScale.Fit, modifier = Modifier .padding(bottom = 4.dp) - .size(72.dp), + .size(96.dp), ) Text( - text = stringResource(id = R.string.search_hint), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.72f), + text = stringResource(id = R.string.no_apps_found), + style = MaterialTheme.typography.bodyMedium.copy( + fontSize = 18.sp + ), + color = colorResource(id = R.color.light_grey), + textAlign = androidx.compose.ui.text.style.TextAlign.Center, ) } } 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 5045464e41acecc21dbc50c4770fed089b27ba31..7146f05768abd7410774b38d3a0541da0111e40f 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 @@ -19,26 +19,44 @@ package foundation.e.apps.ui.compose.components import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue 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.search.v2.ScrollPosition import foundation.e.apps.ui.search.v2.SearchTabType +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import java.util.Locale @@ -46,9 +64,14 @@ import java.util.Locale fun SearchResultsContent( tabs: List, selectedTab: SearchTabType, - resultsByTab: Map>, + fossItems: LazyPagingItems?, + pwaItems: 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 = {}, onShowMoreClick: (Application) -> Unit = {}, @@ -103,49 +126,279 @@ fun SearchResultsContent( .padding(top = 16.dp), ) { page -> val tab = tabs[page] - val items = resultsByTab[tab].orEmpty() - SearchResultList( - items = items, - onItemClick = onResultClick, - onPrimaryActionClick = onPrimaryActionClick, - onShowMoreClick = onShowMoreClick, - onPrivacyClick = onPrivacyClick, - modifier = Modifier.fillMaxSize(), - ) + + 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(), + ) + } + + SearchTabType.COMMON_APPS -> { + PagingPlayStoreResultList( + items = playStoreItems, + searchVersion = searchVersion, + getScrollPosition = getScrollPosition, + onScrollPositionChange = onScrollPositionChange, + onItemClick = onResultClick, + onPrimaryActionClick = onPrimaryActionClick, + onShowMoreClick = onShowMoreClick, + onPrivacyClick = onPrivacyClick, + modifier = Modifier.fillMaxSize(), + ) + } + } } } } @Composable -private fun SearchResultList( - items: List, +private fun PagingPlayStoreResultList( + items: LazyPagingItems?, + searchVersion: Int, + getScrollPosition: (SearchTabType) -> ScrollPosition?, + onScrollPositionChange: (SearchTabType, Int, Int) -> Unit, onItemClick: (Application) -> Unit, onPrimaryActionClick: (Application) -> Unit, onShowMoreClick: (Application) -> Unit, onPrivacyClick: (Application) -> Unit, modifier: Modifier = Modifier, ) { - LazyColumn( - modifier = modifier, - verticalArrangement = Arrangement.spacedBy(12.dp), + val context = LocalContext.current + val lazyItems = items ?: return + val saved = getScrollPosition(SearchTabType.COMMON_APPS) + val listState = rememberSaveable( + SearchTabType.COMMON_APPS, + searchVersion, + saver = LazyListState.Saver ) { - itemsIndexed( - items = items, - key = { index, item -> - item._id.takeIf { it.isNotBlank() } - ?: item.package_name.takeIf { it.isNotBlank() } - ?: "${item.name}-$index" - }, - ) { _, application -> - SearchResultListItem( - application = application, - uiState = application.toSearchResultUiState(), - onItemClick = onItemClick, - onPrimaryActionClick = onPrimaryActionClick, - onShowMoreClick = onShowMoreClick, - onPrivacyClick = onPrivacyClick, - modifier = Modifier.fillMaxWidth(), - ) + LazyListState( + firstVisibleItemIndex = saved?.index ?: 0, + firstVisibleItemScrollOffset = saved?.offset ?: 0 + ) + } + val updatedOnScrollPositionChange by rememberUpdatedState(onScrollPositionChange) + + LaunchedEffect(listState) { + snapshotFlow { listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset } + .collectLatest { pair -> + val index = pair.first + val offset = pair.second + updatedOnScrollPositionChange(SearchTabType.COMMON_APPS, index, offset) + } + } + + val loadState = lazyItems.loadState + + val errorState = loadState.refresh as? LoadState.Error + ?: loadState.prepend as? LoadState.Error + ?: loadState.append as? LoadState.Error + + val isRefreshing = loadState.refresh is LoadState.Loading + val isAppending = loadState.append is LoadState.Loading + val isError = errorState != null + val isEmpty = !isRefreshing && !isError && lazyItems.itemCount == 0 + + Box(modifier = modifier) { + when { + isRefreshing -> { + SearchShimmerList() + } + + isError -> { + SearchErrorState( + onRetry = { lazyItems.retry() }, + modifier = Modifier.fillMaxSize() + ) + } + + isEmpty -> { + SearchPlaceholder( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.Center) + ) + } + + else -> { + LazyColumn( + modifier = Modifier.fillMaxSize(), + state = listState, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + items( + count = lazyItems.itemCount, + key = { index -> + val item = lazyItems.peek(index) + item?.packageName.takeIf { !it.isNullOrBlank() } + ?: item?.id.toString() + }, + ) { index -> + val app = lazyItems[index] + if (app != null) { + val application = app.toApplication(context) + SearchResultListItem( + application = application, + uiState = application.toSearchResultUiState(), + onItemClick = onItemClick, + onPrimaryActionClick = onPrimaryActionClick, + onShowMoreClick = onShowMoreClick, + onPrivacyClick = onPrivacyClick, + modifier = Modifier.fillMaxWidth(), + ) + } else { + SearchResultListItemPlaceholder(modifier = Modifier.fillMaxWidth()) + } + } + + if (isAppending) { + item(key = "append_loader_play_store") { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(modifier = Modifier.size(24.dp)) + } + } + } + } + } + } + } +} + +@Composable +private fun PagingSearchResultList( + items: LazyPagingItems?, + searchVersion: Int, + tab: SearchTabType, + getScrollPosition: (SearchTabType) -> ScrollPosition?, + onScrollPositionChange: (SearchTabType, Int, Int) -> Unit, + onItemClick: (Application) -> Unit, + onPrimaryActionClick: (Application) -> Unit, + onShowMoreClick: (Application) -> Unit, + onPrivacyClick: (Application) -> Unit, + modifier: Modifier = Modifier, +) { + val lazyItems = items ?: return + val saved = getScrollPosition(tab) + val listState = rememberSaveable(tab, searchVersion, saver = LazyListState.Saver) { + LazyListState( + firstVisibleItemIndex = saved?.index ?: 0, + firstVisibleItemScrollOffset = saved?.offset ?: 0 + ) + } + val updatedOnScrollPositionChange by rememberUpdatedState(onScrollPositionChange) + + LaunchedEffect(listState) { + snapshotFlow { listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset } + .collectLatest { pair -> + val index = pair.first + val offset = pair.second + updatedOnScrollPositionChange(tab, index, offset) + } + } + + val loadState = lazyItems.loadState + + val errorState = loadState.refresh as? LoadState.Error + ?: loadState.prepend as? LoadState.Error + ?: loadState.append as? LoadState.Error + + val isRefreshing = loadState.refresh is LoadState.Loading + val isAppending = loadState.append is LoadState.Loading + val isError = errorState != null + val isEmpty = !isRefreshing && !isError && lazyItems.itemCount == 0 + + Box(modifier = modifier) { + when { + isRefreshing -> { + SearchShimmerList( + modifier = Modifier.testTag(SearchResultsContentTestTags.REFRESH_LOADER) + ) + } + + isError -> { + SearchErrorState( + onRetry = { lazyItems.retry() }, + modifier = Modifier.fillMaxSize() + ) + } + + isEmpty -> { + SearchPlaceholder( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.Center) + ) + } + + else -> { + LazyColumn( + modifier = Modifier.fillMaxSize(), + state = listState, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + items( + count = lazyItems.itemCount, + key = { index -> + val item = lazyItems.peek(index) + item?._id.takeIf { !it.isNullOrBlank() } + ?: item?.package_name.takeIf { !it.isNullOrBlank() } + ?: "item-$index" + }, + ) { index -> + val application = lazyItems[index] + if (application != null) { + SearchResultListItem( + application = application, + uiState = application.toSearchResultUiState(), + onItemClick = onItemClick, + onPrimaryActionClick = onPrimaryActionClick, + onShowMoreClick = onShowMoreClick, + onPrivacyClick = onPrivacyClick, + modifier = Modifier.fillMaxWidth(), + ) + } else { + SearchResultListItemPlaceholder(modifier = Modifier.fillMaxWidth()) + } + } + + if (isAppending) { + item(key = "append_loader") { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + modifier = Modifier + .size(24.dp) + .testTag(SearchResultsContentTestTags.APPEND_LOADER) + ) + } + } + } + } + } } } } @@ -197,7 +450,7 @@ private fun Application.toSearchResultUiState(): SearchResultListItemState { showPrivacyScore = false, // Privacy scores are disabled on Search per functional spec. isPrivacyLoading = false, primaryAction = resolvePrimaryActionState(this), - iconUrl = icon_image_path.takeIf { it.isNotBlank() }, + iconUrl = iconUrl, placeholderResId = null, isPlaceholder = false, ) @@ -240,3 +493,8 @@ private fun resolvePrimaryActionState(application: Application): PrimaryActionUi 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 new file mode 100644 index 0000000000000000000000000000000000000000..a8ad4969d941d9b3ffadd71882951546d073ba07 --- /dev/null +++ b/app/src/main/java/foundation/e/apps/ui/compose/components/search/SearchErrorState.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.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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +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.unit.dp +import foundation.e.apps.R + +@Composable +fun SearchErrorState( + onRetry: () -> Unit, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = stringResource(id = R.string.search_error), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onBackground, + ) + Button(onClick = onRetry) { + Text(text = stringResource(id = R.string.retry)) + } + } + } +} diff --git a/app/src/main/java/foundation/e/apps/ui/compose/components/search/SearchLoading.kt b/app/src/main/java/foundation/e/apps/ui/compose/components/search/SearchLoading.kt new file mode 100644 index 0000000000000000000000000000000000000000..89fd22650e9b021ec2e23441d6c98223437adaa8 --- /dev/null +++ b/app/src/main/java/foundation/e/apps/ui/compose/components/search/SearchLoading.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.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import foundation.e.apps.data.application.data.Application +import foundation.e.apps.ui.compose.components.PrimaryActionUiState +import foundation.e.apps.ui.compose.components.SearchResultListItem +import foundation.e.apps.ui.compose.components.SearchResultListItemState + +@Composable +fun SearchShimmerList(modifier: Modifier = Modifier) { + Box( + modifier = modifier + .fillMaxSize(), + contentAlignment = androidx.compose.ui.Alignment.Center + ) { + CircularProgressIndicator() + } +} + +@Composable +fun SearchResultListItemPlaceholder(modifier: Modifier = Modifier) { + SearchResultListItem( + application = Application(), + uiState = placeholderState(), + onItemClick = {}, + onPrimaryActionClick = {}, + onShowMoreClick = {}, + onPrivacyClick = {}, + modifier = modifier + ) +} + +private fun placeholderState() = SearchResultListItemState( + author = "", + ratingText = "", + showRating = false, + sourceTag = "", + showSourceTag = false, + privacyScore = "", + showPrivacyScore = false, + isPrivacyLoading = false, + primaryAction = PrimaryActionUiState( + label = "", + enabled = false, + isInProgress = false, + isFilledStyle = true, + showMore = false + ), + iconUrl = null, + placeholderResId = null, + isPlaceholder = true +) 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 390bc196a745752fa81e281a50ccdcf9eb43dc3d..657adf71ef9ce84bc6decebae195423b3181551e 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 @@ -20,6 +20,7 @@ package foundation.e.apps.ui.compose.screens import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable @@ -34,13 +35,20 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.unit.dp import androidx.lifecycle.Lifecycle 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.search.v2.ScrollPosition import foundation.e.apps.ui.search.v2.SearchTabType import foundation.e.apps.ui.search.v2.SearchUiState +import kotlinx.coroutines.flow.Flow @Composable fun SearchScreen( @@ -52,6 +60,12 @@ fun SearchScreen( onSuggestionSelect: (String) -> Unit, onTabSelect: (SearchTabType) -> Unit, modifier: Modifier = Modifier, + fossPaging: Flow>? = null, + pwaPaging: 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 = {}, @@ -64,10 +78,7 @@ fun SearchScreen( val shouldAutoFocus = !uiState.hasSubmittedSearch var isSearchExpanded by rememberSaveable { mutableStateOf(shouldAutoFocus) } var hasRequestedInitialFocus by rememberSaveable { mutableStateOf(false) } - val selectedTab = uiState.selectedTab val showSuggestions = isSearchExpanded && uiState.isSuggestionVisible - val showResults = - uiState.hasSubmittedSearch && selectedTab != null && uiState.availableTabs.isNotEmpty() LaunchedEffect(lifecycleOwner, shouldAutoFocus) { lifecycleOwner.repeatOnLifecycle(Lifecycle.State.RESUMED) { @@ -122,23 +133,37 @@ fun SearchScreen( .fillMaxSize() .padding(innerPadding) ) { - when { - showResults && selectedTab != null -> { - SearchResultsContent( - tabs = uiState.availableTabs, - selectedTab = selectedTab, - resultsByTab = uiState.resultsByTab, - onTabSelect = onTabSelect, - modifier = Modifier.fillMaxSize(), - onResultClick = onResultClick, - onPrimaryActionClick = onPrimaryActionClick, - onShowMoreClick = onShowMoreClick, onPrivacyClick = onPrivacyClick, - ) - } + val fossItems = fossPaging?.collectAsLazyPagingItems() + val pwaItems = pwaPaging?.collectAsLazyPagingItems() + val playStoreItems = playStorePaging?.collectAsLazyPagingItems() - else -> { - // Suggestions render in the top bar dropdown; leave body empty. - } + val shouldShowResults = + uiState.hasSubmittedSearch && uiState.selectedTab != null && uiState.availableTabs.isNotEmpty() + + if (shouldShowResults) { + SearchResultsContent( + tabs = uiState.availableTabs, + selectedTab = uiState.selectedTab!!, + fossItems = fossItems, + pwaItems = pwaItems, + searchVersion = searchVersion, + getScrollPosition = getScrollPosition, + onScrollPositionChange = onScrollPositionChange, + onTabSelect = onTabSelect, + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + playStoreItems = playStoreItems, + onResultClick = onResultClick, + onPrimaryActionClick = onPrimaryActionClick, + onShowMoreClick = onShowMoreClick, + onPrivacyClick = onPrivacyClick, + ) + } else { + SearchInitialState( + modifier = Modifier + .fillMaxWidth(), + ) } } } diff --git a/app/src/main/java/foundation/e/apps/ui/home/model/HomeChildRVAdapter.kt b/app/src/main/java/foundation/e/apps/ui/home/model/HomeChildRVAdapter.kt index 1519bc2c277e3eebd7fedefc6022ac50f52f1022..8c0155fb2876e9f858190e71225b331f8507db31 100644 --- a/app/src/main/java/foundation/e/apps/ui/home/model/HomeChildRVAdapter.kt +++ b/app/src/main/java/foundation/e/apps/ui/home/model/HomeChildRVAdapter.kt @@ -34,8 +34,6 @@ import com.google.android.material.snackbar.Snackbar import foundation.e.apps.R import foundation.e.apps.data.application.ApplicationInstaller import foundation.e.apps.data.application.data.Application -import foundation.e.apps.data.cleanapk.CleanApkRetrofit -import foundation.e.apps.data.enums.Source import foundation.e.apps.data.enums.Status import foundation.e.apps.data.enums.User import foundation.e.apps.data.login.state.LoginState @@ -82,14 +80,8 @@ class HomeChildRVAdapter( val shimmerDrawable = ShimmerDrawable().apply { setShimmer(shimmer) } holder.binding.apply { - if (homeApp.source == Source.PWA || homeApp.source == Source.OPEN_SOURCE) { - appIcon.load(CleanApkRetrofit.ASSET_URL + homeApp.icon_image_path) { - placeholder(shimmerDrawable) - } - } else { - appIcon.load(homeApp.icon_image_path) { - placeholder(shimmerDrawable) - } + appIcon.load(homeApp.iconUrl) { + placeholder(shimmerDrawable) } appName.text = homeApp.name homeLayout.setOnClickListener { @@ -108,24 +100,31 @@ class HomeChildRVAdapter( Status.INSTALLED -> { handleInstalled(homeApp) } + Status.UPDATABLE -> { handleUpdatable(homeApp) } + Status.UNAVAILABLE -> { handleUnavailable(homeApp, holder) } + Status.QUEUED, Status.AWAITING, Status.DOWNLOADING, Status.DOWNLOADED -> { handleQueued(homeApp) } + Status.INSTALLING -> { handleInstalling() } + Status.BLOCKED -> { handleBlocked() } + Status.INSTALLATION_ISSUE -> { handleInstallationIssue(homeApp) } + else -> {} } } @@ -155,6 +154,7 @@ class HomeChildRVAdapter( view.context.getString(R.string.install_blocked_anonymous) user == User.ANONYMOUS || user == User.NO_GOOGLE -> view.context.getString(R.string.install_blocked_anonymous) + else -> view.context.getString(R.string.install_blocked_google) } if (errorMsg.isNotBlank()) { @@ -263,11 +263,13 @@ class HomeChildRVAdapter( materialButton.enableInstallButton() materialButton.text = materialButton.context.getString(R.string.not_available) } + homeApp.isFree -> { materialButton.enableInstallButton() materialButton.text = materialButton.context.getString(R.string.install) homeChildListItemBinding.progressBarInstall.visibility = View.GONE } + else -> { materialButton.disableInstallButton() materialButton.text = "" 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 f86edb917148d92deaa5dddbd066bc12eae77c02..5759b1dbbdfdc75a1bb158692f4237f366f712af 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 @@ -26,8 +26,10 @@ import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.fragment.findNavController import dagger.hilt.android.AndroidEntryPoint import foundation.e.apps.R +import foundation.e.apps.data.application.data.Application import foundation.e.apps.ui.compose.screens.SearchScreen import foundation.e.apps.ui.compose.theme.AppTheme @@ -50,8 +52,32 @@ class SearchFragmentV2 : Fragment(R.layout.fragment_search_v2) { 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) }, ) } } } + + private fun navigateToApplication(application: Application) { + val packageName = application.package_name + val id = application._id + val source = application.source + val args = Bundle().apply { + putString("id", id) + putString("packageName", packageName) + putSerializable("source", source) + putString("category", "") + putBoolean("isGplayReplaced", false) + putBoolean("isPurchased", application.isPurchased) + } + findNavController().navigate(R.id.applicationFragment, args) + } } 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 186f47aaebfd5b057ddb8a8463eae616aeaa0587..a0d1702a6b74e5fa7de8566746d75e5d22316ed7 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 @@ -20,28 +20,35 @@ package foundation.e.apps.ui.search.v2 import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import androidx.paging.PagingData +import androidx.paging.cachedIn 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.application.data.Ratings -import foundation.e.apps.data.enums.Source +import foundation.e.apps.data.cleanapk.CleanApkRetrofit import foundation.e.apps.data.enums.Source.OPEN_SOURCE import foundation.e.apps.data.enums.Source.PLAY_STORE import foundation.e.apps.data.enums.Source.PWA -import foundation.e.apps.data.enums.Status import foundation.e.apps.data.preference.AppLoungePreference +import foundation.e.apps.data.search.CleanApkSearchParams +import foundation.e.apps.data.search.PlayStorePagingRepository +import foundation.e.apps.data.search.SearchPagingRepository import foundation.e.apps.data.search.SuggestionSource +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +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 private const val SUGGESTION_DEBOUNCE_MS = 500L -private const val FAKE_RESULTS_PER_TAB = 50 enum class SearchTabType { COMMON_APPS, @@ -55,14 +62,25 @@ data class SearchUiState( val isSuggestionVisible: Boolean = false, val availableTabs: List = emptyList(), val selectedTab: SearchTabType? = null, - val resultsByTab: Map> = emptyMap(), val hasSubmittedSearch: Boolean = false, + val searchVersion: Int = 0, +) + +/** + * Captures scroll restoration state for a given search tab. + * index: first visible item index; offset: pixel offset within that item. + */ +data class ScrollPosition( + val index: Int = 0, + val offset: Int = 0, ) @HiltViewModel class SearchViewModelV2 @Inject constructor( private val suggestionSource: SuggestionSource, private val appLoungePreference: AppLoungePreference, + private val searchPagingRepository: SearchPagingRepository, + private val playStorePagingRepository: PlayStorePagingRepository, private val stores: Stores ) : ViewModel() { @@ -76,6 +94,28 @@ 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, + appSource = CleanApkRetrofit.APP_SOURCE_FOSS, + appType = CleanApkRetrofit.APP_TYPE_NATIVE + ) + + val pwaPagingFlow = buildCleanApkPagingFlow( + tab = SearchTabType.PWA, + appSource = CleanApkRetrofit.APP_SOURCE_ANY, + appType = CleanApkRetrofit.APP_TYPE_PWA + ) + + val playStorePagingFlow = buildPlayStorePagingFlow() + private var suggestionJob: Job? = null init { @@ -94,7 +134,6 @@ class SearchViewModelV2 @Inject constructor( if (newQuery.isBlank()) { _uiState.update { current -> if (current.hasSubmittedSearch && current.availableTabs.isNotEmpty()) { - // Keep existing results/tabs visible; just hide suggestions and clear query. current.copy( suggestions = emptyList(), isSuggestionVisible = false, @@ -106,7 +145,6 @@ class SearchViewModelV2 @Inject constructor( suggestions = emptyList(), isSuggestionVisible = false, hasSubmittedSearch = false, - resultsByTab = emptyMap(), availableTabs = visibleTabs, selectedTab = visibleTabs.firstOrNull(), query = "", @@ -158,9 +196,8 @@ class SearchViewModelV2 @Inject constructor( suggestions = emptyList(), isSuggestionVisible = false, hasSubmittedSearch = false, - resultsByTab = emptyMap(), - availableTabs = visibleTabs, - selectedTab = visibleTabs.firstOrNull(), + availableTabs = resolveVisibleTabs(), + selectedTab = resolveVisibleTabs().firstOrNull(), ) } } @@ -178,12 +215,6 @@ class SearchViewModelV2 @Inject constructor( val selectedTab = _uiState.value.selectedTab?.takeIf { visibleTabs.contains(it) } ?: visibleTabs.firstOrNull() - val results = if (visibleTabs.isEmpty()) { - emptyMap() - } else { - buildResultsForTabs(trimmedQuery, visibleTabs, emptyMap()) - } - _uiState.update { current -> current.copy( query = trimmedQuery, @@ -191,9 +222,17 @@ class SearchViewModelV2 @Inject constructor( isSuggestionVisible = false, availableTabs = visibleTabs, selectedTab = selectedTab, - resultsByTab = results, hasSubmittedSearch = visibleTabs.isNotEmpty(), + searchVersion = current.searchVersion + 1, + ) + } + + if (visibleTabs.isNotEmpty()) { + searchRequests.value = SearchRequest( + query = trimmedQuery, + visibleTabs = visibleTabs ) + _scrollPositions.update { emptyMap() } } } @@ -214,24 +253,35 @@ class SearchViewModelV2 @Inject constructor( val selectedTab = current.selectedTab?.takeIf { visibleTabs.contains(it) } ?: visibleTabs.firstOrNull() - val updatedResults = if (current.hasSubmittedSearch && visibleTabs.isNotEmpty()) { - buildResultsForTabs( - query = current.query, - visibleTabs = visibleTabs, - existing = current.resultsByTab, - ) - } else { - emptyMap() - } - current.copy( availableTabs = visibleTabs, selectedTab = selectedTab, - resultsByTab = updatedResults, hasSubmittedSearch = current.hasSubmittedSearch && visibleTabs.isNotEmpty(), isSuggestionVisible = current.isSuggestionVisible && appLoungePreference.isPlayStoreSelected(), ) } + + 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 + ) + } + } + + fun getScrollPosition(tab: SearchTabType): ScrollPosition? = _scrollPositions.value[tab] + + fun updateScrollPosition(tab: SearchTabType, index: Int, offset: Int) { + _scrollPositions.update { current -> + current + (tab to ScrollPosition(index, offset)) + } } private fun resolveVisibleTabs(): List = @@ -244,58 +294,51 @@ class SearchViewModelV2 @Inject constructor( } } - private fun buildResultsForTabs( - query: String, - visibleTabs: List, - existing: Map>, - ): Map> { - if (query.isBlank()) return emptyMap() - - return buildMap { - visibleTabs.forEach { tab -> - val preserved = existing[tab] - put(tab, preserved ?: generateFakeResultsFor(tab, query)) + @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 + ) + ) } } - } - - private fun generateFakeResultsFor(tab: SearchTabType, query: String): List { - val displayQuery = query.ifBlank { "Result" } - val source = when (tab) { - SearchTabType.COMMON_APPS -> Source.PLAY_STORE - SearchTabType.OPEN_SOURCE -> Source.OPEN_SOURCE - SearchTabType.PWA -> Source.PWA - } - - return (1..FAKE_RESULTS_PER_TAB).map { index -> - val packageName = when (tab) { - SearchTabType.COMMON_APPS -> "com.example.standard.$index" - SearchTabType.OPEN_SOURCE -> "org.example.foss.$index" - SearchTabType.PWA -> "org.example.pwa.$index" + .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 + ) + } } + .flatMapLatest { it } + .cachedIn(viewModelScope) - Application( - _id = "$tab-$index", - name = "${tab.toReadable()} $index for $displayQuery", - author = "Author $index", - package_name = packageName, - source = source, - ratings = Ratings(usageQualityScore = 4.0 + (index % 3) * 0.1), - is_pwa = tab == SearchTabType.PWA, - status = when (index % 4) { - 0 -> Status.UNAVAILABLE - 1 -> Status.UPDATABLE - 2 -> Status.INSTALLED - else -> Status.DOWNLOADING - }, - price = if (index % 5 == 0) "$1.$index" else "", - ) - } - } - - private fun SearchTabType.toReadable(): String = when (this) { - SearchTabType.COMMON_APPS -> "Standard app" - SearchTabType.OPEN_SOURCE -> "Open source app" - SearchTabType.PWA -> "Web app" + companion object { + private const val DEFAULT_PLAY_STORE_PAGE_SIZE = 20 } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 667fe751d764a4de0b4b7fdc225c48c8fc979c96..69a761f4f7f730f54f56896be91567924358d8e6 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -35,6 +35,7 @@ OPEN SOURCE WEB APPS No apps found… + Error in search Applications diff --git a/app/src/test/java/foundation/e/apps/data/application/data/ApplicationTest.kt b/app/src/test/java/foundation/e/apps/data/application/data/ApplicationTest.kt index 63e03495cd0ec9a9b10b96ed1d2e3e82ca95030a..c47d52642a26d9c9e89db52ee33a70e8bce21e50 100644 --- a/app/src/test/java/foundation/e/apps/data/application/data/ApplicationTest.kt +++ b/app/src/test/java/foundation/e/apps/data/application/data/ApplicationTest.kt @@ -1,6 +1,8 @@ package foundation.e.apps.data.application.data import com.google.common.truth.Truth.assertThat +import foundation.e.apps.data.cleanapk.CleanApkRetrofit +import foundation.e.apps.data.enums.Source import foundation.e.apps.data.enums.Type import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner @@ -74,4 +76,46 @@ class ApplicationTest { assertThat(app.hasExodusPrivacyRating()).isTrue() assertThat(missing.hasExodusPrivacyRating()).isFalse() } + + @Test + fun iconUrlReturnsNullWhenBlank() { + val app = Application(icon_image_path = " ") + + assertThat(app.iconUrl).isNull() + } + + @Test + fun iconUrlUsesCleanApkAssetsForOpenSourceRelativePath() { + val app = Application( + source = Source.OPEN_SOURCE, + icon_image_path = "icons/app.png" + ) + + assertThat(app.iconUrl).isEqualTo(CleanApkRetrofit.ASSET_URL + "icons/app.png") + } + + @Test + fun iconUrlReturnsAbsolutePwaIconUrl() { + val app = Application( + source = Source.PWA, + icon_image_path = "https://example.org/icon.png" + ) + + assertThat(app.iconUrl).isEqualTo("https://example.org/icon.png") + } + + @Test + fun iconUrlReturnsRawPathForPlayStoreAndSystemApps() { + val playStoreApp = Application( + source = Source.PLAY_STORE, + icon_image_path = "content://playstore/icon.png" + ) + val systemApp = Application( + source = Source.SYSTEM_APP, + icon_image_path = "system/icon.png" + ) + + assertThat(playStoreApp.iconUrl).isEqualTo("content://playstore/icon.png") + assertThat(systemApp.iconUrl).isEqualTo("system/icon.png") + } } diff --git a/app/src/test/java/foundation/e/apps/data/cleanapk/CleanApkSearchHelperTest.kt b/app/src/test/java/foundation/e/apps/data/cleanapk/CleanApkSearchHelperTest.kt index 59756302f721587a44260b716e3c6dfba610866e..6513bf4a6c1dda160b10023357161793d4d2ee7b 100644 --- a/app/src/test/java/foundation/e/apps/data/cleanapk/CleanApkSearchHelperTest.kt +++ b/app/src/test/java/foundation/e/apps/data/cleanapk/CleanApkSearchHelperTest.kt @@ -23,12 +23,6 @@ package foundation.e.apps.data.cleanapk import com.google.common.truth.Truth.assertThat -import foundation.e.apps.data.application.data.Application -import foundation.e.apps.data.cleanapk.data.search.Search -import foundation.e.apps.data.cleanapk.repositories.NUMBER_OF_ITEMS -import foundation.e.apps.data.cleanapk.repositories.NUMBER_OF_PAGES -import foundation.e.apps.data.enums.Source -import foundation.e.apps.utils.SystemInfoProvider import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every @@ -36,10 +30,19 @@ import io.mockk.mockk import io.mockk.mockkObject import io.mockk.unmockkObject import kotlinx.coroutines.test.runTest +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.ResponseBody.Companion.toResponseBody import org.junit.After import org.junit.Before import org.junit.Test import retrofit2.Response +import kotlin.test.assertFailsWith +import foundation.e.apps.data.application.data.Application +import foundation.e.apps.data.cleanapk.data.search.Search +import foundation.e.apps.data.cleanapk.repositories.NUMBER_OF_ITEMS +import foundation.e.apps.data.cleanapk.repositories.NUMBER_OF_PAGES +import foundation.e.apps.data.enums.Source +import foundation.e.apps.utils.SystemInfoProvider class CleanApkSearchHelperTest { @@ -79,7 +82,7 @@ class CleanApkSearchHelperTest { coEvery { cleanApkRetrofit.searchApps(any(), any(), any(), any(), any(), any(), any()) } returns Response.success( - Search(apps = listOf(pwaApp, nativeApp)) + Search(apps = listOf(pwaApp, nativeApp), success = true) ) val result = helper.getSearchResults( @@ -93,7 +96,7 @@ class CleanApkSearchHelperTest { keyword = "signal", source = CleanApkRetrofit.APP_SOURCE_FOSS, type = CleanApkRetrofit.APP_TYPE_ANY, - nres = NUMBER_OF_ITEMS, + pageSize = NUMBER_OF_ITEMS, page = NUMBER_OF_PAGES, by = null, architectures = architectures @@ -105,25 +108,27 @@ class CleanApkSearchHelperTest { } @Test - fun `getSearchResults returns empty list when response body missing`() = runTest { + fun `getSearchResults throws when response body missing`() = runTest { every { SystemInfoProvider.getSupportedArchitectureList() } returns emptyList() coEvery { cleanApkRetrofit.searchApps(any(), any(), any(), any(), any(), any(), any()) } returns Response.success(null) - val result = helper.getSearchResults( - keyword = "none", - appSource = CleanApkRetrofit.APP_SOURCE_FOSS, - appType = CleanApkRetrofit.APP_TYPE_NATIVE - ) + val exception = assertFailsWith { + helper.getSearchResults( + keyword = "none", + appSource = CleanApkRetrofit.APP_SOURCE_FOSS, + appType = CleanApkRetrofit.APP_TYPE_NATIVE + ) + } - assertThat(result).isEmpty() + assertThat(exception.message).isEqualTo("CleanAPK search failed: empty body") coVerify { cleanApkRetrofit.searchApps( keyword = "none", source = CleanApkRetrofit.APP_SOURCE_FOSS, type = CleanApkRetrofit.APP_TYPE_NATIVE, - nres = NUMBER_OF_ITEMS, + pageSize = NUMBER_OF_ITEMS, page = NUMBER_OF_PAGES, by = null, architectures = emptyList() @@ -131,6 +136,71 @@ class CleanApkSearchHelperTest { } } + @Test + fun `getSearchResultPage throws when response unsuccessful`() = runTest { + every { SystemInfoProvider.getSupportedArchitectureList() } returns emptyList() + val responseBody = "error".toResponseBody("text/plain".toMediaType()) + coEvery { + cleanApkRetrofit.searchApps(any(), any(), any(), any(), any(), any(), any()) + } returns Response.error(500, responseBody) + + val exception = assertFailsWith { + helper.getSearchResultPage( + keyword = "fail", + appSource = CleanApkRetrofit.APP_SOURCE_FOSS, + appType = CleanApkRetrofit.APP_TYPE_NATIVE, + page = 1, + pageSize = 20, + ) + } + + assertThat(exception.message).isEqualTo("CleanAPK search failed: HTTP 500") + } + + @Test + fun `getSearchResultPage throws when response success false`() = runTest { + every { SystemInfoProvider.getSupportedArchitectureList() } returns emptyList() + coEvery { + cleanApkRetrofit.searchApps(any(), any(), any(), any(), any(), any(), any()) + } returns Response.success(Search(apps = emptyList(), success = false)) + + val exception = assertFailsWith { + helper.getSearchResultPage( + keyword = "fail", + appSource = CleanApkRetrofit.APP_SOURCE_FOSS, + appType = CleanApkRetrofit.APP_TYPE_NATIVE, + page = 1, + pageSize = 20, + ) + } + + assertThat(exception.message).isEqualTo("CleanAPK search failed: success=false") + } + + @Test + fun `getSearchResultPage maps source on apps`() = runTest { + val architectures = listOf("arm64-v8a") + every { SystemInfoProvider.getSupportedArchitectureList() } returns architectures + val pwaApp = Application(_id = "pwa", is_pwa = true) + val nativeApp = Application(_id = "native", is_pwa = false) + coEvery { + cleanApkRetrofit.searchApps(any(), any(), any(), any(), any(), any(), any()) + } returns Response.success( + Search(apps = listOf(pwaApp, nativeApp), numberOfPages = 2, success = true) + ) + + val result = helper.getSearchResultPage( + keyword = "apps", + appSource = CleanApkRetrofit.APP_SOURCE_FOSS, + appType = CleanApkRetrofit.APP_TYPE_NATIVE, + page = 1, + pageSize = 20, + ) + + assertThat(result.apps.first { it._id == "pwa" }.source).isEqualTo(Source.PWA) + assertThat(result.apps.first { it._id == "native" }.source).isEqualTo(Source.OPEN_SOURCE) + } + private fun invokeMapSource(app: Application): Source { val method = CleanApkSearchHelper::class.java.getDeclaredMethod("mapSource", Application::class.java) diff --git a/app/src/test/java/foundation/e/apps/data/search/CleanApkSearchPagingRepositoryTest.kt b/app/src/test/java/foundation/e/apps/data/search/CleanApkSearchPagingRepositoryTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..b241b5ff16e5082851058f689cb70f36fa343001 --- /dev/null +++ b/app/src/test/java/foundation/e/apps/data/search/CleanApkSearchPagingRepositoryTest.kt @@ -0,0 +1,161 @@ +/* + * 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 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.cleanapk.CleanApkSearchHelper +import foundation.e.apps.data.cleanapk.data.search.Search +import foundation.e.apps.ui.applicationlist.ApplicationDiffUtil +import foundation.e.apps.util.MainCoroutineRule +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.TestScope +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class CleanApkSearchPagingRepositoryTest { + + @get:Rule + val mainCoroutineRule = MainCoroutineRule() + + private val cleanApkSearchHelper = mockk() + private lateinit var repository: CleanApkSearchPagingRepository + + @Before + fun setUp() { + repository = CleanApkSearchPagingRepository(cleanApkSearchHelper) + } + + @Test + fun `first page request uses provided search params`() = runTest(mainCoroutineRule.testDispatcher) { + val keyword = "notes" + val appSource = "cleanapk" + val appType = "apps" + val pageSize = 15 + val params = CleanApkSearchParams(keyword, appSource, appType, pageSize) + + coEvery { + cleanApkSearchHelper.getSearchResultPage(keyword, appSource, appType, 1, pageSize) + } returns searchPage("Notes") + + val apps = collectApps(params) + + assertThat(apps).hasSize(1) + assertThat(apps.first().name).isEqualTo("Notes") + coVerify(exactly = 1) { + cleanApkSearchHelper.getSearchResultPage(keyword, appSource, appType, 1, pageSize) + } + } + + @Test + fun `custom page size is honored`() = runTest(mainCoroutineRule.testDispatcher) { + val params = CleanApkSearchParams( + keyword = "weather", + appSource = "cleanapk", + appType = "apps", + pageSize = 5 + ) + + coEvery { + cleanApkSearchHelper.getSearchResultPage("weather", "cleanapk", "apps", 1, 5) + } returns searchPage("Weather") + + val apps = collectApps(params) + + assertThat(apps).hasSize(1) + coVerify(exactly = 1) { + cleanApkSearchHelper.getSearchResultPage("weather", "cleanapk", "apps", 1, 5) + } + } + + @Test + fun `each flow triggers a new page 1 request`() = runTest(mainCoroutineRule.testDispatcher) { + val params = CleanApkSearchParams( + keyword = "calendar", + appSource = "cleanapk", + appType = "apps", + pageSize = 20 + ) + + coEvery { + cleanApkSearchHelper.getSearchResultPage("calendar", "cleanapk", "apps", 1, 20) + } returns searchPage("Calendar") + + collectApps(params) + collectApps(params) + + coVerify(exactly = 2) { + cleanApkSearchHelper.getSearchResultPage("calendar", "cleanapk", "apps", 1, 20) + } + } + + private suspend fun TestScope.collectApps(params: CleanApkSearchParams): List { + val pagingData = repository.cleanApkSearch(params).first() + return collectApplications(pagingData) + } + + private suspend fun TestScope.collectApplications( + pagingData: PagingData + ): List { + val differ = AsyncPagingDataDiffer( + diffCallback = ApplicationDiffUtil(), + updateCallback = NoopListCallback(), + mainDispatcher = mainCoroutineRule.testDispatcher, + workerDispatcher = mainCoroutineRule.testDispatcher + ) + + val job = backgroundScope.launch { + differ.submitData(pagingData) + } + differ.onPagesUpdatedFlow.first() + val items = differ.snapshot().items + job.cancel() + job.join() + return items + } + + private fun searchPage(appName: String, totalPages: Int = 1): Search { + return Search( + apps = listOf(Application(name = appName, _id = appName)), + numberOfPages = totalPages, + success = true + ) + } + + 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/data/search/CleanApkSearchPagingSourceTest.kt b/app/src/test/java/foundation/e/apps/data/search/CleanApkSearchPagingSourceTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..6682067db790d16df3bd35f537e865677e877cb9 --- /dev/null +++ b/app/src/test/java/foundation/e/apps/data/search/CleanApkSearchPagingSourceTest.kt @@ -0,0 +1,207 @@ +/* + * 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 androidx.paging.PagingConfig +import androidx.paging.PagingSource +import androidx.paging.PagingState +import com.google.common.truth.Truth.assertThat +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.ResponseBody.Companion.toResponseBody +import org.junit.Before +import org.junit.Test +import retrofit2.HttpException +import retrofit2.Response +import foundation.e.apps.data.application.data.Application +import foundation.e.apps.data.cleanapk.CleanApkSearchHelper +import foundation.e.apps.data.cleanapk.data.search.Search +import java.io.IOException + +class CleanApkSearchPagingSourceTest { + + private val cleanApkSearchHelper = mockk() + private val params = CleanApkSearchParams( + keyword = "query", + appSource = "source", + appType = "type", + pageSize = 2 + ) + private lateinit var pagingSource: CleanApkSearchPagingSource + + @Before + fun setUp() { + pagingSource = CleanApkSearchPagingSource(cleanApkSearchHelper, params) + } + + @Test + fun `load returns keys for first page`() = runTest { + coEvery { + cleanApkSearchHelper.getSearchResultPage("query", "source", "type", 1, 2) + } returns Search( + apps = listOf(Application(name = "First")), + numberOfPages = 3, + success = true + ) + + val result = pagingSource.load( + PagingSource.LoadParams.Refresh(key = null, loadSize = 2, placeholdersEnabled = false) + ) as PagingSource.LoadResult.Page + + assertThat(result.data).hasSize(1) + assertThat(result.prevKey).isNull() + assertThat(result.nextKey).isEqualTo(2) + } + + @Test + fun `load returns keys for middle page`() = runTest { + coEvery { + cleanApkSearchHelper.getSearchResultPage("query", "source", "type", 2, 2) + } returns Search( + apps = listOf(Application(name = "Middle")), + numberOfPages = 3, + success = true + ) + + val result = pagingSource.load( + PagingSource.LoadParams.Refresh(key = 2, loadSize = 2, placeholdersEnabled = false) + ) as PagingSource.LoadResult.Page + + assertThat(result.prevKey).isEqualTo(1) + assertThat(result.nextKey).isEqualTo(3) + } + + @Test + fun `load returns no next key for last page`() = runTest { + coEvery { + cleanApkSearchHelper.getSearchResultPage("query", "source", "type", 3, 2) + } returns Search( + apps = listOf(Application(name = "Last")), + numberOfPages = 3, + success = true + ) + + val result = pagingSource.load( + PagingSource.LoadParams.Refresh(key = 3, loadSize = 2, placeholdersEnabled = false) + ) as PagingSource.LoadResult.Page + + assertThat(result.prevKey).isEqualTo(2) + assertThat(result.nextKey).isNull() + } + + @Test + fun `getRefreshKey prefers prev key`() { + val page = PagingSource.LoadResult.Page( + data = listOf(Application(name = "App")), + prevKey = 1, + nextKey = 3 + ) + + val state = PagingState( + pages = listOf(page), + anchorPosition = 0, + config = PagingConfig(pageSize = 2), + leadingPlaceholderCount = 0 + ) + + val refreshKey = pagingSource.getRefreshKey(state) + + assertThat(refreshKey).isEqualTo(2) + } + + @Test + fun `getRefreshKey falls back to next key`() { + val page = PagingSource.LoadResult.Page( + data = listOf(Application(name = "App")), + prevKey = null, + nextKey = 2 + ) + + val state = PagingState( + pages = listOf(page), + anchorPosition = 0, + config = PagingConfig(pageSize = 2), + leadingPlaceholderCount = 0 + ) + + val refreshKey = pagingSource.getRefreshKey(state) + + assertThat(refreshKey).isEqualTo(1) + } + + @Test + fun `getRefreshKey returns null when no keys`() { + val page = PagingSource.LoadResult.Page( + data = listOf(Application(name = "App")), + prevKey = null, + nextKey = null + ) + + val state = PagingState( + pages = listOf(page), + anchorPosition = 0, + config = PagingConfig(pageSize = 2), + leadingPlaceholderCount = 0 + ) + + val refreshKey = pagingSource.getRefreshKey(state) + + assertThat(refreshKey).isNull() + } + + @Test + fun `load returns error on IOException`() = runTest { + coEvery { cleanApkSearchHelper.getSearchResultPage(any(), any(), any(), any(), any()) } throws + IOException("offline") + + val result = pagingSource.load( + PagingSource.LoadParams.Refresh(key = null, loadSize = 2, placeholdersEnabled = false) + ) + + assertThat(result).isInstanceOf(PagingSource.LoadResult.Error::class.java) + } + + @Test + fun `load returns error on HttpException`() = runTest { + val responseBody = "error".toResponseBody("text/plain".toMediaType()) + val httpException = HttpException(Response.error(500, responseBody)) + coEvery { cleanApkSearchHelper.getSearchResultPage(any(), any(), any(), any(), any()) } throws + httpException + + val result = pagingSource.load( + PagingSource.LoadParams.Refresh(key = null, loadSize = 2, placeholdersEnabled = false) + ) + + assertThat(result).isInstanceOf(PagingSource.LoadResult.Error::class.java) + } + + @Test + fun `load returns error on IllegalStateException`() = runTest { + coEvery { cleanApkSearchHelper.getSearchResultPage(any(), any(), any(), any(), any()) } throws + IllegalStateException("invalid") + + val result = pagingSource.load( + PagingSource.LoadParams.Refresh(key = null, loadSize = 2, placeholdersEnabled = false) + ) + + assertThat(result).isInstanceOf(PagingSource.LoadResult.Error::class.java) + } +} diff --git a/app/src/test/java/foundation/e/apps/data/search/PlayStorePagingRepositoryTest.kt b/app/src/test/java/foundation/e/apps/data/search/PlayStorePagingRepositoryTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..c413e746dd1b52d6b360f7a45248df786da30af2 --- /dev/null +++ b/app/src/test/java/foundation/e/apps/data/search/PlayStorePagingRepositoryTest.kt @@ -0,0 +1,48 @@ +/* + * 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 com.google.common.truth.Truth.assertThat +import io.mockk.mockk +import org.junit.Test + +class PlayStorePagingRepositoryTest { + + private val playStoreWebSearch = mockk(relaxed = true) + private val repository = PlayStorePagingRepository(playStoreWebSearch) + + @Test + fun `paging config uses provided size and defaults`() { + val config = repository.buildPagingConfig(pageSize = 42) + + assertThat(config.pageSize).isEqualTo(42) + assertThat(config.enablePlaceholders).isFalse() + assertThat(config.prefetchDistance).isEqualTo(2) + } + + @Test + fun `paging source factory returns new instances`() { + val factory = repository.buildPagingSourceFactory("query") + + val first = factory() + val second = factory() + + assertThat(first).isNotSameInstanceAs(second) + } +} diff --git a/app/src/test/java/foundation/e/apps/data/search/PlayStorePagingSourceTest.kt b/app/src/test/java/foundation/e/apps/data/search/PlayStorePagingSourceTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..c61929cef659cfd5191d9d2bdc4ec9b523912d46 --- /dev/null +++ b/app/src/test/java/foundation/e/apps/data/search/PlayStorePagingSourceTest.kt @@ -0,0 +1,252 @@ +/* + * 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 androidx.paging.PagingConfig +import androidx.paging.PagingSource +import androidx.paging.PagingState +import com.aurora.gplayapi.data.models.App +import com.aurora.gplayapi.data.models.StreamBundle +import com.aurora.gplayapi.data.models.StreamCluster +import com.google.common.truth.Truth.assertThat +import foundation.e.apps.data.playstore.utils.GplayHttpRequestException +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import java.io.IOException + +class PlayStorePagingSourceTest { + + private val playStoreWebSearch = FakePlayStoreWebSearch() + private lateinit var pagingSource: PlayStorePagingSource + + @Before + fun setUp() { + pagingSource = PlayStorePagingSource( + query = "query", + playStoreWebSearch = playStoreWebSearch + ) + } + + @Test + fun `load returns empty page when bundle has no clusters`() = runTest { + playStoreWebSearch.searchResultsHandler = { streamBundle() } + + val result = pagingSource.load(refreshParams()) as PagingSource.LoadResult.Page + + assertThat(result.data).isEmpty() + assertThat(result.prevKey).isNull() + assertThat(result.nextKey).isNull() + } + + @Test + fun `load returns apps from bundle clusters`() = runTest { + playStoreWebSearch.searchResultsHandler = { + streamBundle( + clusters = mapOf( + 1 to streamCluster(apps = listOf(appWithPackage("one"))), + 2 to streamCluster(apps = listOf(appWithPackage("two"))) + ) + ) + } + + val result = pagingSource.load(refreshParams()) as PagingSource.LoadResult.Page + + assertThat(result.data.map { it.packageName }).containsExactly("one", "two") + assertThat(result.prevKey).isNull() + assertThat(result.nextKey).isEqualTo(2) + } + + @Test + fun `load consumes stream urls before bundle next page`() = runTest { + playStoreWebSearch.searchResultsHandler = { + streamBundle( + nextUrl = "bundle-next", + clusters = mapOf( + 1 to streamCluster( + nextUrl = "stream-next", + apps = listOf(appWithPackage("first")) + ) + ) + ) + } + playStoreWebSearch.nextStreamClusterHandler = { _, _ -> + streamCluster(apps = listOf(appWithPackage("stream-app"))) + } + + pagingSource.load(refreshParams()) + val result = pagingSource.load(nextPageParams()) as PagingSource.LoadResult.Page + + assertThat(result.data.map { it.packageName }).containsExactly("stream-app") + assertThat(playStoreWebSearch.nextStreamClusterRequests).containsExactly("stream-next") + assertThat(playStoreWebSearch.nextStreamBundleRequests).isEmpty() + } + + @Test + fun `load uses bundle next page when no stream urls remain`() = runTest { + playStoreWebSearch.searchResultsHandler = { + streamBundle( + nextUrl = "bundle-next", + clusters = mapOf(1 to streamCluster(apps = listOf(appWithPackage("first")))) + ) + } + playStoreWebSearch.nextStreamBundleHandler = { _, _ -> + streamBundle( + clusters = mapOf(1 to streamCluster(apps = listOf(appWithPackage("bundle-app")))) + ) + } + + pagingSource.load(refreshParams()) + val result = pagingSource.load(nextPageParams()) as PagingSource.LoadResult.Page + + assertThat(result.data.map { it.packageName }).containsExactly("bundle-app") + assertThat(playStoreWebSearch.nextStreamBundleRequests).containsExactly("bundle-next") + } + + @Test + fun `load returns empty when no next urls remain`() = runTest { + playStoreWebSearch.searchResultsHandler = { + streamBundle( + clusters = mapOf(1 to streamCluster(apps = listOf(appWithPackage("first")))) + ) + } + + pagingSource.load(refreshParams()) + val result = pagingSource.load(nextPageParams()) as PagingSource.LoadResult.Page + + assertThat(result.data).isEmpty() + assertThat(result.nextKey).isNull() + } + + @Test + fun `load deduplicates apps by package name`() = runTest { + playStoreWebSearch.searchResultsHandler = { + streamBundle( + clusters = mapOf( + 1 to streamCluster(apps = listOf(appWithPackage("dup"))), + 2 to streamCluster(apps = listOf(appWithPackage("dup"))) + ) + ) + } + + val result = pagingSource.load(refreshParams()) as PagingSource.LoadResult.Page + + assertThat(result.data.map { it.packageName }).containsExactly("dup") + } + + @Test + fun `load returns error on gplay http exception`() = runTest { + playStoreWebSearch.searchResultsHandler = { + throw GplayHttpRequestException(500, "failure") + } + + val result = pagingSource.load(refreshParams()) + + assertThat(result).isInstanceOf(PagingSource.LoadResult.Error::class.java) + } + + @Test + fun `load returns error on io exception`() = runTest { + playStoreWebSearch.searchResultsHandler = { throw IOException("offline") } + + val result = pagingSource.load(refreshParams()) + + assertThat(result).isInstanceOf(PagingSource.LoadResult.Error::class.java) + } + + @Test + fun `load returns error on illegal state exception`() = runTest { + playStoreWebSearch.searchResultsHandler = { throw IllegalStateException("invalid") } + + val result = pagingSource.load(refreshParams()) + + assertThat(result).isInstanceOf(PagingSource.LoadResult.Error::class.java) + } + + @Test + fun `getRefreshKey prefers prev key`() { + val page = PagingSource.LoadResult.Page( + data = listOf(appWithPackage("app")), + prevKey = 1, + nextKey = 3 + ) + + val state = PagingState( + pages = listOf(page), + anchorPosition = 0, + config = PagingConfig(pageSize = 2), + leadingPlaceholderCount = 0 + ) + + val refreshKey = pagingSource.getRefreshKey(state) + + assertThat(refreshKey).isEqualTo(2) + } + + private fun refreshParams(): PagingSource.LoadParams = + PagingSource.LoadParams.Refresh(key = null, loadSize = 20, placeholdersEnabled = false) + + private fun nextPageParams(): PagingSource.LoadParams = + PagingSource.LoadParams.Refresh(key = 2, loadSize = 20, placeholdersEnabled = false) + + private fun appWithPackage(packageName: String): App { + val app = mockk(relaxed = true) + every { app.packageName } returns packageName + return app + } + + private fun streamBundle( + nextUrl: String = "", + clusters: Map = emptyMap(), + ): StreamBundle = StreamBundle( + streamNextPageUrl = nextUrl, + streamClusters = clusters + ) + + private fun streamCluster( + nextUrl: String = "", + apps: List = emptyList(), + ): StreamCluster = StreamCluster( + clusterNextPageUrl = nextUrl, + clusterAppList = apps + ) + + private class FakePlayStoreWebSearch : PlayStoreWebSearch { + var searchResultsHandler: (String) -> StreamBundle = { StreamBundle() } + var nextStreamBundleHandler: (String, String) -> StreamBundle = { _, _ -> StreamBundle() } + var nextStreamClusterHandler: (String, String) -> StreamCluster = { _, _ -> StreamCluster() } + + val nextStreamBundleRequests = mutableListOf() + val nextStreamClusterRequests = mutableListOf() + + override suspend fun searchResults(query: String): StreamBundle = searchResultsHandler(query) + + override suspend fun nextStreamBundle(query: String, nextUrl: String): StreamBundle { + nextStreamBundleRequests.add(nextUrl) + return nextStreamBundleHandler(query, nextUrl) + } + + override suspend fun nextStreamCluster(query: String, nextUrl: String): StreamCluster { + nextStreamClusterRequests.add(nextUrl) + return nextStreamClusterHandler(query, nextUrl) + } + } +} 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 7a95ccc03f2d8a96a4eeaa3533b4fbe64eaabb88..7e4e7aa1b5fdd8745d764e88597305bfce751653 100644 --- a/app/src/test/java/foundation/e/apps/ui/search/v2/SearchViewModelV2Test.kt +++ b/app/src/test/java/foundation/e/apps/ui/search/v2/SearchViewModelV2Test.kt @@ -18,17 +18,32 @@ 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 +import foundation.e.apps.data.application.data.Application +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.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.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.first +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -48,6 +63,8 @@ class SearchViewModelV2Test { private lateinit var suggestionSource: FakeSuggestionSource private lateinit var preference: AppLoungePreference + private lateinit var searchPagingRepository: SearchPagingRepository + private lateinit var playStorePagingRepository: PlayStorePagingRepository private lateinit var stores: Stores private var playStoreSelected = true private var openSourceSelected = true @@ -58,6 +75,8 @@ class SearchViewModelV2Test { fun setUp() { suggestionSource = FakeSuggestionSource() preference = mockk(relaxed = true) + searchPagingRepository = mockk(relaxed = true) + playStorePagingRepository = mockk(relaxed = true) every { preference.isPlayStoreSelected() } answers { playStoreSelected } every { preference.isOpenSourceSelected() } answers { openSourceSelected } @@ -128,7 +147,6 @@ class SearchViewModelV2Test { viewModel.onQueryChanged(" ") val state = viewModel.uiState.value - assertTrue(state.resultsByTab.isEmpty()) assertEquals(visibleTabs(), state.availableTabs) assertEquals(visibleTabs().firstOrNull(), state.selectedTab) assertFalse(state.hasSubmittedSearch) @@ -137,13 +155,11 @@ class SearchViewModelV2Test { @Test fun `blank query after submit hides suggestions but keeps results`() = runTest { viewModel.onSearchSubmitted("query") - val resultsBefore = viewModel.uiState.value.resultsByTab val tabsBefore = viewModel.uiState.value.availableTabs viewModel.onQueryChanged(" ") val state = viewModel.uiState.value - assertEquals(resultsBefore, state.resultsByTab) assertEquals(tabsBefore, state.availableTabs) assertTrue(state.hasSubmittedSearch) assertFalse(state.isSuggestionVisible) @@ -153,13 +169,11 @@ class SearchViewModelV2Test { @Test fun `clear query after submit retains tabs and results`() = runTest { viewModel.onSearchSubmitted("query") - val resultsBefore = viewModel.uiState.value.resultsByTab val tabsBefore = viewModel.uiState.value.availableTabs viewModel.onQueryCleared() val state = viewModel.uiState.value - assertEquals(resultsBefore, state.resultsByTab) assertEquals(tabsBefore, state.availableTabs) assertTrue(state.hasSubmittedSearch) assertEquals("", state.query) @@ -178,14 +192,29 @@ class SearchViewModelV2Test { val state = viewModel.uiState.value assertEquals("spaced query", state.query) assertEquals(visibleTabs(), state.availableTabs) - assertTrue(state.resultsByTab.keys.containsAll(visibleTabs())) - assertTrue(state.resultsByTab[SearchTabType.PWA]!!.all { it.name.contains("spaced query") }) - assertTrue(state.resultsByTab.values.all { it.size == 50 }) assertTrue(state.hasSubmittedSearch) assertTrue(state.suggestions.isEmpty()) assertFalse(state.isSuggestionVisible) } + @Test + fun `search submit triggers paging request with trimmed query`() = runTest { + playStoreSelected = true + openSourceSelected = true + pwaSelected = false + buildViewModel() + every { searchPagingRepository.cleanApkSearch(any()) } returns flowOf(PagingData.empty()) + val paramsSlot = slot() + + viewModel.onSearchSubmitted(" Signal ") + viewModel.fossPagingFlow.first() + + verify { searchPagingRepository.cleanApkSearch(capture(paramsSlot)) } + assertEquals("Signal", paramsSlot.captured.keyword) + assertEquals(CleanApkRetrofit.APP_SOURCE_FOSS, paramsSlot.captured.appSource) + assertEquals(CleanApkRetrofit.APP_TYPE_NATIVE, paramsSlot.captured.appType) + } + @Test fun `search submit with no visible tabs yields no results`() = runTest { playStoreSelected = false @@ -198,7 +227,6 @@ class SearchViewModelV2Test { viewModel.onQueryCleared() val state = viewModel.uiState.value assertTrue(state.availableTabs.isEmpty()) - assertTrue(state.resultsByTab.isEmpty()) assertNull(state.selectedTab) assertFalse(state.hasSubmittedSearch) } @@ -211,7 +239,6 @@ class SearchViewModelV2Test { viewModel.onSearchSubmitted(" ") val state = viewModel.uiState.value - assertTrue(state.resultsByTab.isEmpty()) assertFalse(state.hasSubmittedSearch) assertEquals(visibleTabs(), state.availableTabs) assertEquals(visibleTabs().firstOrNull(), state.selectedTab) @@ -233,8 +260,6 @@ class SearchViewModelV2Test { val state = viewModel.uiState.value assertEquals(listOf(SearchTabType.OPEN_SOURCE), state.availableTabs) assertEquals(SearchTabType.OPEN_SOURCE, state.selectedTab) - assertTrue(state.resultsByTab.keys == setOf(SearchTabType.OPEN_SOURCE)) - assertTrue(state.resultsByTab[SearchTabType.OPEN_SOURCE]!!.all { it.name.contains("apps") }) assertTrue(state.hasSubmittedSearch) } @@ -266,7 +291,6 @@ class SearchViewModelV2Test { val state = viewModel.uiState.value assertTrue(state.availableTabs.isEmpty()) - assertTrue(state.resultsByTab.isEmpty()) assertNull(state.selectedTab) assertFalse(state.hasSubmittedSearch) } @@ -285,6 +309,75 @@ class SearchViewModelV2Test { assertEquals(SearchTabType.OPEN_SOURCE, viewModel.uiState.value.selectedTab) } + @Test + fun `hidden tab emits empty paging data`() = runTest { + playStoreSelected = true + openSourceSelected = false + pwaSelected = false + buildViewModel() + every { searchPagingRepository.cleanApkSearch(any()) } returns flowOf( + PagingData.from(listOf(sampleApp("ShouldNotAppear"))) + ) + + viewModel.onSearchSubmitted("apps") + val pagingData = viewModel.fossPagingFlow.first() + val items = collectApplications(pagingData) + + assertTrue(items.isEmpty()) + verify(exactly = 0) { searchPagingRepository.cleanApkSearch(any()) } + } + + @Test + fun `play store paging requests repository when tab visible`() = runTest { + playStoreSelected = true + openSourceSelected = false + pwaSelected = false + buildViewModel() + every { playStorePagingRepository.playStoreSearch(any(), any()) } returns flowOf(PagingData.empty()) + + viewModel.onSearchSubmitted(" android ") + viewModel.playStorePagingFlow.first() + + verify { playStorePagingRepository.playStoreSearch("android", 20) } + } + + @Test + fun `play store paging emits empty when tab hidden`() = runTest { + playStoreSelected = false + openSourceSelected = true + pwaSelected = false + buildViewModel() + every { playStorePagingRepository.playStoreSearch(any(), any()) } returns flowOf( + PagingData.from(listOf(samplePlayStoreApp("ignored"))) + ) + + viewModel.onSearchSubmitted("apps") + val pagingData = viewModel.playStorePagingFlow.first() + val items = collectPlayStoreApps(pagingData) + + assertTrue(items.isEmpty()) + verify(exactly = 0) { playStorePagingRepository.playStoreSearch(any(), any()) } + } + + @Test + fun `play store paging emits empty on blank query`() = runTest { + playStoreSelected = true + openSourceSelected = false + pwaSelected = false + buildViewModel() + every { playStorePagingRepository.playStoreSearch(any(), any()) } returns flowOf( + PagingData.from(listOf(samplePlayStoreApp("ignored"))) + ) + + stores.enableStore(Source.OPEN_SOURCE) + runStoreUpdates() + val pagingData = viewModel.playStorePagingFlow.first() + val items = collectPlayStoreApps(pagingData) + + assertTrue(items.isEmpty()) + verify(exactly = 0) { playStorePagingRepository.playStoreSearch(any(), any()) } + } + @Test fun `on suggestion selected delegates to search submission`() = runTest { playStoreSelected = true @@ -308,11 +401,44 @@ class SearchViewModelV2Test { runStoreUpdates() val state = viewModel.uiState.value - assertEquals(listOf(SearchTabType.COMMON_APPS, SearchTabType.OPEN_SOURCE), state.availableTabs) + assertEquals( + listOf(SearchTabType.COMMON_APPS, SearchTabType.OPEN_SOURCE), + state.availableTabs + ) assertEquals(SearchTabType.COMMON_APPS, state.selectedTab) assertFalse(state.hasSubmittedSearch) } + @Test + fun `scroll position stores per tab`() = runTest { + viewModel.updateScrollPosition(SearchTabType.OPEN_SOURCE, 4, 12) + viewModel.updateScrollPosition(SearchTabType.PWA, 1, 8) + + assertEquals(ScrollPosition(4, 12), viewModel.getScrollPosition(SearchTabType.OPEN_SOURCE)) + assertEquals(ScrollPosition(1, 8), viewModel.getScrollPosition(SearchTabType.PWA)) + } + + @Test + fun `search submit increments search version`() = runTest { + viewModel.onSearchSubmitted("first") + val firstVersion = viewModel.uiState.value.searchVersion + + viewModel.onSearchSubmitted("second") + + assertEquals(firstVersion + 1, viewModel.uiState.value.searchVersion) + } + + @Test + fun `search submit clears scroll positions`() = runTest { + viewModel.updateScrollPosition(SearchTabType.COMMON_APPS, 6, 4) + viewModel.updateScrollPosition(SearchTabType.OPEN_SOURCE, 2, 9) + + viewModel.onSearchSubmitted("android") + + assertNull(viewModel.getScrollPosition(SearchTabType.COMMON_APPS)) + assertNull(viewModel.getScrollPosition(SearchTabType.OPEN_SOURCE)) + } + private fun advanceDebounce() { mainCoroutineRule.testDispatcher.scheduler.advanceTimeBy(DEBOUNCE_MS) mainCoroutineRule.testDispatcher.scheduler.runCurrent() @@ -322,6 +448,32 @@ class SearchViewModelV2Test { mainCoroutineRule.testDispatcher.scheduler.runCurrent() } + 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 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) @@ -330,6 +482,41 @@ class SearchViewModelV2Test { private fun buildViewModel() { stores = buildStores() - viewModel = SearchViewModelV2(suggestionSource, preference, stores) + viewModel = SearchViewModelV2( + suggestionSource, + preference, + searchPagingRepository, + playStorePagingRepository, + stores + ) + } + + 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 + } + + 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, + ) + + private fun samplePlayStoreApp(name: String): App { + val app = mockk(relaxed = true) + every { app.packageName } returns name + return app } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4f431dd63006b4d720e564ddeba2b7e312ed11c3..b04e563c53461c06eecf3afa7306a74704bdfbf2 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -46,6 +46,7 @@ detekt = "1.23.8" ktlint = "10.2.0" navigation = "2.8.5" okhttp = "4.12.0" +paging = "3.3.5" photoview = "2.3.0" preferenceKtx = "1.2.1" protobufJavalite = "4.28.2" @@ -117,6 +118,8 @@ moshi-kotlin = { module = "com.squareup.moshi:moshi-kotlin", version.ref = "mosh navigation-ui-ktx = { module = "androidx.navigation:navigation-ui-ktx", version.ref = "navigation" } navigation-fragment-ktx = { module = "androidx.navigation:navigation-fragment-ktx", version.ref = "navigation" } okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } +paging-compose = { module = "androidx.paging:paging-compose", version.ref = "paging" } +paging-runtime-ktx = { module = "androidx.paging:paging-runtime-ktx", version.ref = "paging" } photoview = { module = "com.github.Baseflow:PhotoView", version.ref = "photoview" } preference-ktx = { module = "androidx.preference:preference-ktx", version.ref = "preferenceKtx" } protobuf-javalite = { module = "com.google.protobuf:protobuf-javalite", version.ref = "protobufJavalite" }