From 231c0ff0c2f929fc498ba36e571f5a7521a821b8 Mon Sep 17 00:00:00 2001 From: Fahim Masud Choudhury Date: Wed, 31 Dec 2025 20:37:41 +0600 Subject: [PATCH 1/4] feat: implement pagination for CleanAPK search results --- app/build.gradle | 2 + .../e/apps/data/cleanapk/CleanApkRetrofit.kt | 48 ++--- .../data/cleanapk/CleanApkSearchHelper.kt | 42 +++- .../apps/data/cleanapk/data/search/Search.kt | 6 +- .../search/CleanApkSearchPagingRepository.kt | 48 +++++ .../data/search/CleanApkSearchPagingSource.kt | 77 ++++++++ .../apps/data/search/CleanApkSearchParams.kt | 26 +++ .../data/search/SearchPagingRepository.kt | 27 +++ .../e/apps/di/SearchPagingModule.kt | 37 ++++ .../compose/components/SearchInitialState.kt | 74 +++++++ .../compose/components/SearchPlaceholder.kt | 25 ++- .../components/SearchResultsContent.kt | 182 ++++++++++++++---- .../components/search/SearchErrorState.kt | 63 ++++++ .../components/search/SearchLoading.kt | 74 +++++++ .../e/apps/ui/compose/screens/SearchScreen.kt | 57 ++++-- .../e/apps/ui/search/v2/SearchFragmentV2.kt | 24 +++ .../e/apps/ui/search/v2/SearchViewModelV2.kt | 163 ++++++++-------- app/src/main/res/values/strings.xml | 1 + .../data/cleanapk/CleanApkSearchHelperTest.kt | 23 ++- .../ui/search/v2/SearchViewModelV2Test.kt | 23 +-- gradle/libs.versions.toml | 3 + 21 files changed, 827 insertions(+), 198 deletions(-) create mode 100644 app/src/main/java/foundation/e/apps/data/search/CleanApkSearchPagingRepository.kt create mode 100644 app/src/main/java/foundation/e/apps/data/search/CleanApkSearchPagingSource.kt create mode 100644 app/src/main/java/foundation/e/apps/data/search/CleanApkSearchParams.kt create mode 100644 app/src/main/java/foundation/e/apps/data/search/SearchPagingRepository.kt create mode 100644 app/src/main/java/foundation/e/apps/di/SearchPagingModule.kt create mode 100644 app/src/main/java/foundation/e/apps/ui/compose/components/SearchInitialState.kt create mode 100644 app/src/main/java/foundation/e/apps/ui/compose/components/search/SearchErrorState.kt create mode 100644 app/src/main/java/foundation/e/apps/ui/compose/components/search/SearchLoading.kt diff --git a/app/build.gradle b/app/build.gradle index d71def501..aa3fad36c 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/main/java/foundation/e/apps/data/cleanapk/CleanApkRetrofit.kt b/app/src/main/java/foundation/e/apps/data/cleanapk/CleanApkRetrofit.kt index c778d4b5b..281a67d02 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 3cbfb4e01..97de5c53e 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 3bec2357d..be66b5eac 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 000000000..d6bfb54e5 --- /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 000000000..4465fb242 --- /dev/null +++ b/app/src/main/java/foundation/e/apps/data/search/CleanApkSearchPagingSource.kt @@ -0,0 +1,77 @@ +/* + * Copyright (C) 2026 e Foundation + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package foundation.e.apps.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 data = response.apps + + val nextKey = if (page < totalPages) page + 1 else null + val prevKey = if (page > INITIAL_PAGE) page - 1 else null + + LoadResult.Page( + data = data, + 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 000000000..3422dbeb5 --- /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/SearchPagingRepository.kt b/app/src/main/java/foundation/e/apps/data/search/SearchPagingRepository.kt new file mode 100644 index 000000000..970eb55a7 --- /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 000000000..1fa9cacb6 --- /dev/null +++ b/app/src/main/java/foundation/e/apps/di/SearchPagingModule.kt @@ -0,0 +1,37 @@ +/* + * Copyright (C) 2026 e Foundation + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package foundation.e.apps.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.SearchPagingRepository +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +abstract class SearchPagingModule { + @Binds + @Singleton + abstract fun bindSearchPagingRepository( + impl: CleanApkSearchPagingRepository + ): SearchPagingRepository +} 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 000000000..be1e7a536 --- /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 696936035..60c415be5 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 5045464e4..c0e9bac5d 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,41 @@ 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.res.stringResource import androidx.compose.ui.unit.dp +import androidx.paging.LoadState +import androidx.paging.compose.LazyPagingItems import foundation.e.apps.R 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.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,7 +61,10 @@ import java.util.Locale fun SearchResultsContent( tabs: List, selectedTab: SearchTabType, - resultsByTab: Map>, + fossItems: LazyPagingItems?, + pwaItems: LazyPagingItems?, + getScrollPosition: (SearchTabType) -> ScrollPosition?, + onScrollPositionChange: (SearchTabType, Int, Int) -> Unit, onTabSelect: (SearchTabType) -> Unit, modifier: Modifier = Modifier, onResultClick: (Application) -> Unit = {}, @@ -103,49 +121,142 @@ 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, + tab = tab, + getScrollPosition = getScrollPosition, + onScrollPositionChange = onScrollPositionChange, + onItemClick = onResultClick, + onPrimaryActionClick = onPrimaryActionClick, + onShowMoreClick = onShowMoreClick, + onPrivacyClick = onPrivacyClick, + modifier = Modifier.fillMaxSize(), + ) + } + + SearchTabType.COMMON_APPS -> { + SearchPlaceholder( + modifier = Modifier + .fillMaxSize() + ) + } + } } } } @Composable -private fun SearchResultList( - items: List, +private fun PagingSearchResultList( + items: LazyPagingItems?, + 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, ) { - LazyColumn( - modifier = modifier, - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - 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(), - ) + val lazyItems = items ?: return + val saved = getScrollPosition(tab) + val listState = rememberSaveable(tab, 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 isRefreshing = loadState.refresh is LoadState.Loading + val isAppending = loadState.append is LoadState.Loading + val isError = loadState.refresh is LoadState.Error + 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?._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)) + } + } + } + } + } } } } @@ -197,7 +308,8 @@ 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 = icon_image_path.takeIf { it.isNotBlank() } + ?.let { CleanApkRetrofit.ASSET_URL + it }, placeholderResId = null, isPlaceholder = false, ) diff --git a/app/src/main/java/foundation/e/apps/ui/compose/components/search/SearchErrorState.kt b/app/src/main/java/foundation/e/apps/ui/compose/components/search/SearchErrorState.kt new file mode 100644 index 000000000..a8ad4969d --- /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 000000000..89fd22650 --- /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 390bc196a..b8de563ec 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,19 @@ 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 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 +59,10 @@ fun SearchScreen( onSuggestionSelect: (String) -> Unit, onTabSelect: (SearchTabType) -> Unit, modifier: Modifier = Modifier, + fossPaging: Flow>? = null, + pwaPaging: Flow>? = null, + getScrollPosition: (SearchTabType) -> ScrollPosition? = { null }, + onScrollPositionChange: (SearchTabType, Int, Int) -> Unit = { _, _, _ -> }, onResultClick: (Application) -> Unit = {}, onPrimaryActionClick: (Application) -> Unit = {}, onShowMoreClick: (Application) -> Unit = {}, @@ -64,10 +75,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 +130,34 @@ 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() - 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, + getScrollPosition = getScrollPosition, + onScrollPositionChange = onScrollPositionChange, + onTabSelect = onTabSelect, + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + onResultClick = onResultClick, + onPrimaryActionClick = onPrimaryActionClick, + onShowMoreClick = onShowMoreClick, + onPrivacyClick = onPrivacyClick, + ) + } else { + SearchInitialState( + modifier = Modifier + .fillMaxWidth(), + ) } } } 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 f86edb917..248edc532 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,30 @@ class SearchFragmentV2 : Fragment(R.layout.fragment_search_v2) { onSubmitSearch = searchViewModel::onSearchSubmitted, onSuggestionSelect = searchViewModel::onSuggestionSelected, onTabSelect = searchViewModel::onTabSelected, + fossPaging = searchViewModel.fossPagingFlow, + pwaPaging = searchViewModel.pwaPagingFlow, + 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 186f47aae..1c03c9824 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,34 @@ 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.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 +61,23 @@ data class SearchUiState( val isSuggestionVisible: Boolean = false, val availableTabs: List = emptyList(), val selectedTab: SearchTabType? = null, - val resultsByTab: Map> = emptyMap(), val hasSubmittedSearch: Boolean = false, ) +/** + * 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 stores: Stores ) : ViewModel() { @@ -76,6 +91,26 @@ 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 + ) + private var suggestionJob: Job? = null init { @@ -94,7 +129,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 +140,6 @@ class SearchViewModelV2 @Inject constructor( suggestions = emptyList(), isSuggestionVisible = false, hasSubmittedSearch = false, - resultsByTab = emptyMap(), availableTabs = visibleTabs, selectedTab = visibleTabs.firstOrNull(), query = "", @@ -144,6 +177,7 @@ class SearchViewModelV2 @Inject constructor( fun onQueryCleared() { suggestionJob?.cancel() + searchRequests.value = null _uiState.update { current -> if (current.hasSubmittedSearch && current.availableTabs.isNotEmpty()) { current.copy( @@ -158,7 +192,6 @@ class SearchViewModelV2 @Inject constructor( suggestions = emptyList(), isSuggestionVisible = false, hasSubmittedSearch = false, - resultsByTab = emptyMap(), availableTabs = visibleTabs, selectedTab = visibleTabs.firstOrNull(), ) @@ -178,12 +211,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,10 +218,16 @@ class SearchViewModelV2 @Inject constructor( isSuggestionVisible = false, availableTabs = visibleTabs, selectedTab = selectedTab, - resultsByTab = results, hasSubmittedSearch = visibleTabs.isNotEmpty(), ) } + + if (visibleTabs.isNotEmpty()) { + searchRequests.value = SearchRequest( + query = trimmedQuery, + visibleTabs = visibleTabs + ) + } } fun onTabSelected(tab: SearchTabType) { @@ -214,24 +247,30 @@ 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 + ) + } + } + + 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 +283,26 @@ 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)) - } - } - } - - 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" + @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 { + searchPagingRepository.cleanApkSearch( + CleanApkSearchParams( + keyword = request.query, + appSource = appSource, + appType = appType + ) + ) } - - 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" - } + .flatMapLatest { it } + .cachedIn(viewModelScope) } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 667fe751d..69a761f4f 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/cleanapk/CleanApkSearchHelperTest.kt b/app/src/test/java/foundation/e/apps/data/cleanapk/CleanApkSearchHelperTest.kt index 59756302f..267ec7adf 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 @@ -40,6 +40,7 @@ import org.junit.After import org.junit.Before import org.junit.Test import retrofit2.Response +import kotlin.test.assertFailsWith class CleanApkSearchHelperTest { @@ -79,7 +80,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 +94,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 +106,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() 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 7a95ccc03..d38b2e66b 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 @@ -25,6 +25,7 @@ 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.FakeSuggestionSource +import foundation.e.apps.data.search.SearchPagingRepository import foundation.e.apps.util.MainCoroutineRule import io.mockk.every import io.mockk.mockk @@ -48,6 +49,7 @@ class SearchViewModelV2Test { private lateinit var suggestionSource: FakeSuggestionSource private lateinit var preference: AppLoungePreference + private lateinit var searchPagingRepository: SearchPagingRepository private lateinit var stores: Stores private var playStoreSelected = true private var openSourceSelected = true @@ -58,6 +60,7 @@ class SearchViewModelV2Test { fun setUp() { suggestionSource = FakeSuggestionSource() preference = mockk(relaxed = true) + searchPagingRepository = mockk(relaxed = true) every { preference.isPlayStoreSelected() } answers { playStoreSelected } every { preference.isOpenSourceSelected() } answers { openSourceSelected } @@ -128,7 +131,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 +139,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 +153,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,9 +176,6 @@ 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) @@ -198,7 +193,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 +205,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 +226,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 +257,6 @@ class SearchViewModelV2Test { val state = viewModel.uiState.value assertTrue(state.availableTabs.isEmpty()) - assertTrue(state.resultsByTab.isEmpty()) assertNull(state.selectedTab) assertFalse(state.hasSubmittedSearch) } @@ -308,7 +298,10 @@ 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) } @@ -330,6 +323,6 @@ class SearchViewModelV2Test { private fun buildViewModel() { stores = buildStores() - viewModel = SearchViewModelV2(suggestionSource, preference, stores) + viewModel = SearchViewModelV2(suggestionSource, preference, searchPagingRepository, stores) } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4f431dd63..b04e563c5 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" } -- GitLab From 5e11d5000cf2120dac24c7855b7547ea9cc02fbb Mon Sep 17 00:00:00 2001 From: Fahim Masud Choudhury Date: Thu, 22 Jan 2026 13:20:04 +0600 Subject: [PATCH 2/4] test: cover search paging flows and load states - add paging source tests for key calculation and error handling - extend CleanApkSearchHelper and SearchViewModelV2 coverage for paging requests and scroll state - update SearchResultsContent UI tests and add loader test tags for refresh/append assertions --- .../components/SearchResultsContentTest.kt | 282 ++++++++++++------ .../apps/data/application/data/Application.kt | 18 ++ .../data/search/CleanApkSearchPagingSource.kt | 3 +- .../data/search/PlayStorePagingRepository.kt | 50 ++++ .../apps/data/search/PlayStorePagingSource.kt | 138 +++++++++ .../e/apps/di/SearchPagingModule.kt | 15 + .../ui/application/ApplicationFragment.kt | 5 +- .../ApplicationListRVAdapter.kt | 15 +- .../components/SearchResultsContent.kt | 162 +++++++++- .../e/apps/ui/compose/screens/SearchScreen.kt | 6 + .../apps/ui/home/model/HomeChildRVAdapter.kt | 22 +- .../e/apps/ui/search/v2/SearchFragmentV2.kt | 2 + .../e/apps/ui/search/v2/SearchViewModelV2.kt | 42 ++- .../data/cleanapk/CleanApkSearchHelperTest.kt | 79 ++++- .../CleanApkSearchPagingRepositoryTest.kt | 161 ++++++++++ .../search/CleanApkSearchPagingSourceTest.kt | 207 +++++++++++++ .../ui/search/v2/SearchViewModelV2Test.kt | 95 +++++- 17 files changed, 1163 insertions(+), 139 deletions(-) create mode 100644 app/src/main/java/foundation/e/apps/data/search/PlayStorePagingRepository.kt create mode 100644 app/src/main/java/foundation/e/apps/data/search/PlayStorePagingSource.kt create mode 100644 app/src/test/java/foundation/e/apps/data/search/CleanApkSearchPagingRepositoryTest.kt create mode 100644 app/src/test/java/foundation/e/apps/data/search/CleanApkSearchPagingSourceTest.kt diff --git a/app/src/androidTest/java/foundation/e/apps/ui/compose/components/SearchResultsContentTest.kt b/app/src/androidTest/java/foundation/e/apps/ui/compose/components/SearchResultsContentTest.kt index 5e6d12cb2..3d11cba97 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,6 @@ 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 @RunWith(AndroidJUnit4::class) class SearchResultsContentTest { @@ -51,62 +57,54 @@ 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, + getScrollPosition = { null }, + onScrollPositionChange = { _, _, _ -> }, onTabSelect = { tab -> selectedTab = tab selectedTabs.add(tab) @@ -116,17 +114,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)) } } @@ -135,57 +130,162 @@ class SearchResultsContentTest { val notAvailable = composeRule.activity.getString(R.string.not_available) val openLabel = composeRule.activity.getString(R.string.open) + 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("4.4").assertIsDisplayed() + composeRule.onNodeWithText(openLabel).assertIsDisplayed() + composeRule.onNodeWithText(notAvailable).assertIsDisplayed() + composeRule.onAllNodesWithText("4.9").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, + ) { 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, + 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 aa5767318..13cedfb02 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/search/CleanApkSearchPagingSource.kt b/app/src/main/java/foundation/e/apps/data/search/CleanApkSearchPagingSource.kt index 4465fb242..e2c4c7d71 100644 --- a/app/src/main/java/foundation/e/apps/data/search/CleanApkSearchPagingSource.kt +++ b/app/src/main/java/foundation/e/apps/data/search/CleanApkSearchPagingSource.kt @@ -56,13 +56,12 @@ class CleanApkSearchPagingSource( ) val totalPages = response.numberOfPages - val data = response.apps val nextKey = if (page < totalPages) page + 1 else null val prevKey = if (page > INITIAL_PAGE) page - 1 else null LoadResult.Page( - data = data, + data = response.apps, prevKey = prevKey, nextKey = nextKey ) 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 000000000..95e7e9327 --- /dev/null +++ b/app/src/main/java/foundation/e/apps/data/search/PlayStorePagingRepository.kt @@ -0,0 +1,50 @@ +/* + * 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 foundation.e.apps.data.playstore.utils.GPlayHttpClient +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class PlayStorePagingRepository @Inject constructor( + private val gPlayHttpClient: GPlayHttpClient, +) { + + fun playStoreSearch(query: String, pageSize: Int): Flow> { + return Pager( + config = PagingConfig( + pageSize = pageSize, + enablePlaceholders = false, + prefetchDistance = 2 + ), + pagingSourceFactory = { + PlayStorePagingSource( + query = query, + gPlayHttpClient = gPlayHttpClient, + ) + } + ).flow + } +} 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 000000000..d30ce0552 --- /dev/null +++ b/app/src/main/java/foundation/e/apps/data/search/PlayStorePagingSource.kt @@ -0,0 +1,138 @@ +/* + * 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 com.aurora.gplayapi.helpers.web.WebSearchHelper +import foundation.e.apps.data.playstore.utils.GPlayHttpClient +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, + gPlayHttpClient: GPlayHttpClient, +) : PagingSource() { + + private val webSearchHelper = WebSearchHelper().using(gPlayHttpClient) + + 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 = webSearchHelper.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 = webSearchHelper.nextStreamCluster(query, streamUrl) + listOf(cluster).collectApplications() + } + } + + !nextBundleUrl.isNullOrBlank() -> { + val bundle = webSearchHelper.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/di/SearchPagingModule.kt b/app/src/main/java/foundation/e/apps/di/SearchPagingModule.kt index 1fa9cacb6..a4a0659d2 100644 --- a/app/src/main/java/foundation/e/apps/di/SearchPagingModule.kt +++ b/app/src/main/java/foundation/e/apps/di/SearchPagingModule.kt @@ -20,9 +20,12 @@ package foundation.e.apps.di import dagger.Binds import dagger.Module +import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent +import foundation.e.apps.data.playstore.utils.GPlayHttpClient import foundation.e.apps.data.search.CleanApkSearchPagingRepository +import foundation.e.apps.data.search.PlayStorePagingRepository import foundation.e.apps.data.search.SearchPagingRepository import javax.inject.Singleton @@ -34,4 +37,16 @@ abstract class SearchPagingModule { abstract fun bindSearchPagingRepository( impl: CleanApkSearchPagingRepository ): SearchPagingRepository + + companion object { + @Provides + @Singleton + fun providePlayStorePagingRepository( + gPlayHttpClient: GPlayHttpClient + ): PlayStorePagingRepository { + return PlayStorePagingRepository( + gPlayHttpClient = gPlayHttpClient + ) + } + } } 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 beb105b56..f551b6af9 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 bb38a19bb..812071cd0 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/SearchResultsContent.kt b/app/src/main/java/foundation/e/apps/ui/compose/components/SearchResultsContent.kt index c0e9bac5d..03adc5786 100644 --- a/app/src/main/java/foundation/e/apps/ui/compose/components/SearchResultsContent.kt +++ b/app/src/main/java/foundation/e/apps/ui/compose/components/SearchResultsContent.kt @@ -39,13 +39,16 @@ 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.cleanapk.CleanApkRetrofit +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 @@ -63,10 +66,12 @@ fun SearchResultsContent( selectedTab: SearchTabType, 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 = {}, @@ -132,6 +137,7 @@ fun SearchResultsContent( SearchTabType.OPEN_SOURCE, SearchTabType.PWA -> { PagingSearchResultList( items = items, + searchVersion = searchVersion, tab = tab, getScrollPosition = getScrollPosition, onScrollPositionChange = onScrollPositionChange, @@ -144,9 +150,16 @@ fun SearchResultsContent( } SearchTabType.COMMON_APPS -> { - SearchPlaceholder( - modifier = Modifier - .fillMaxSize() + PagingPlayStoreResultList( + items = playStoreItems, + searchVersion = searchVersion, + getScrollPosition = getScrollPosition, + onScrollPositionChange = onScrollPositionChange, + onItemClick = onResultClick, + onPrimaryActionClick = onPrimaryActionClick, + onShowMoreClick = onShowMoreClick, + onPrivacyClick = onPrivacyClick, + modifier = Modifier.fillMaxSize(), ) } } @@ -154,9 +167,125 @@ fun SearchResultsContent( } } +@Composable +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, +) { + val context = LocalContext.current + val lazyItems = items ?: return + val saved = getScrollPosition(SearchTabType.COMMON_APPS) + val listState = rememberSaveable( + SearchTabType.COMMON_APPS, + 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(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, @@ -168,7 +297,7 @@ private fun PagingSearchResultList( ) { val lazyItems = items ?: return val saved = getScrollPosition(tab) - val listState = rememberSaveable(tab, saver = LazyListState.Saver) { + val listState = rememberSaveable(tab, searchVersion, saver = LazyListState.Saver) { LazyListState( firstVisibleItemIndex = saved?.index ?: 0, firstVisibleItemScrollOffset = saved?.offset ?: 0 @@ -186,15 +315,20 @@ private fun PagingSearchResultList( } val loadState = lazyItems.loadState + val errorState = loadState.refresh as? LoadState.Error + ?: loadState.prepend as? LoadState.Error + ?: loadState.append as? LoadState.Error val isRefreshing = loadState.refresh is LoadState.Loading val isAppending = loadState.append is LoadState.Loading - val isError = loadState.refresh is LoadState.Error + val isError = errorState != null val isEmpty = !isRefreshing && !isError && lazyItems.itemCount == 0 Box(modifier = modifier) { when { isRefreshing -> { - SearchShimmerList() + SearchShimmerList( + modifier = Modifier.testTag(SearchResultsContentTestTags.REFRESH_LOADER) + ) } isError -> { @@ -251,7 +385,11 @@ private fun PagingSearchResultList( .padding(vertical = 16.dp), contentAlignment = Alignment.Center ) { - CircularProgressIndicator(modifier = Modifier.size(24.dp)) + CircularProgressIndicator( + modifier = Modifier + .size(24.dp) + .testTag(SearchResultsContentTestTags.APPEND_LOADER) + ) } } } @@ -308,8 +446,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() } - ?.let { CleanApkRetrofit.ASSET_URL + it }, + iconUrl = iconUrl, placeholderResId = null, isPlaceholder = false, ) @@ -352,3 +489,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/screens/SearchScreen.kt b/app/src/main/java/foundation/e/apps/ui/compose/screens/SearchScreen.kt index b8de563ec..657adf71e 100644 --- a/app/src/main/java/foundation/e/apps/ui/compose/screens/SearchScreen.kt +++ b/app/src/main/java/foundation/e/apps/ui/compose/screens/SearchScreen.kt @@ -41,6 +41,7 @@ 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 @@ -61,6 +62,8 @@ fun SearchScreen( 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 = {}, @@ -132,6 +135,7 @@ fun SearchScreen( ) { val fossItems = fossPaging?.collectAsLazyPagingItems() val pwaItems = pwaPaging?.collectAsLazyPagingItems() + val playStoreItems = playStorePaging?.collectAsLazyPagingItems() val shouldShowResults = uiState.hasSubmittedSearch && uiState.selectedTab != null && uiState.availableTabs.isNotEmpty() @@ -142,12 +146,14 @@ fun SearchScreen( 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, 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 1519bc2c2..8c0155fb2 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 248edc532..5759b1dbb 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 @@ -54,6 +54,8 @@ class SearchFragmentV2 : Fragment(R.layout.fragment_search_v2) { 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) 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 1c03c9824..a0d1702a6 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 @@ -31,6 +31,7 @@ import foundation.e.apps.data.enums.Source.PLAY_STORE import foundation.e.apps.data.enums.Source.PWA 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 @@ -62,6 +63,7 @@ data class SearchUiState( val availableTabs: List = emptyList(), val selectedTab: SearchTabType? = null, val hasSubmittedSearch: Boolean = false, + val searchVersion: Int = 0, ) /** @@ -78,6 +80,7 @@ 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() { @@ -111,6 +114,8 @@ class SearchViewModelV2 @Inject constructor( appType = CleanApkRetrofit.APP_TYPE_PWA ) + val playStorePagingFlow = buildPlayStorePagingFlow() + private var suggestionJob: Job? = null init { @@ -177,7 +182,6 @@ class SearchViewModelV2 @Inject constructor( fun onQueryCleared() { suggestionJob?.cancel() - searchRequests.value = null _uiState.update { current -> if (current.hasSubmittedSearch && current.availableTabs.isNotEmpty()) { current.copy( @@ -192,8 +196,8 @@ class SearchViewModelV2 @Inject constructor( suggestions = emptyList(), isSuggestionVisible = false, hasSubmittedSearch = false, - availableTabs = visibleTabs, - selectedTab = visibleTabs.firstOrNull(), + availableTabs = resolveVisibleTabs(), + selectedTab = resolveVisibleTabs().firstOrNull(), ) } } @@ -219,6 +223,7 @@ class SearchViewModelV2 @Inject constructor( availableTabs = visibleTabs, selectedTab = selectedTab, hasSubmittedSearch = visibleTabs.isNotEmpty(), + searchVersion = current.searchVersion + 1, ) } @@ -227,6 +232,7 @@ class SearchViewModelV2 @Inject constructor( query = trimmedQuery, visibleTabs = visibleTabs ) + _scrollPositions.update { emptyMap() } } } @@ -262,6 +268,11 @@ class SearchViewModelV2 @Inject constructor( query = currentQuery, visibleTabs = visibleTabs ) + } else if (!_uiState.value.hasSubmittedSearch) { + searchRequests.value = SearchRequest( + query = "", + visibleTabs = visibleTabs + ) } } @@ -293,6 +304,8 @@ class SearchViewModelV2 @Inject constructor( .mapLatest { request -> if (!request.visibleTabs.contains(tab)) { flowOf(PagingData.empty()) + } else if (request.query.isBlank()) { + flowOf(PagingData.empty()) } else { searchPagingRepository.cleanApkSearch( CleanApkSearchParams( @@ -305,4 +318,27 @@ class SearchViewModelV2 @Inject constructor( } .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) + + companion object { + private const val DEFAULT_PLAY_STORE_PAGE_SIZE = 20 + } } 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 267ec7adf..6513bf4a6 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,11 +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 { @@ -134,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 000000000..b241b5ff1 --- /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 000000000..6682067db --- /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/ui/search/v2/SearchViewModelV2Test.kt b/app/src/test/java/foundation/e/apps/ui/search/v2/SearchViewModelV2Test.kt index d38b2e66b..56b9cfbee 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,18 +18,30 @@ package foundation.e.apps.ui.search.v2 +import androidx.paging.AsyncPagingDataDiffer +import androidx.paging.PagingData +import androidx.recyclerview.widget.ListUpdateCallback 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 @@ -50,6 +62,7 @@ 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 @@ -61,6 +74,7 @@ class SearchViewModelV2Test { suggestionSource = FakeSuggestionSource() preference = mockk(relaxed = true) searchPagingRepository = mockk(relaxed = true) + playStorePagingRepository = mockk(relaxed = true) every { preference.isPlayStoreSelected() } answers { playStoreSelected } every { preference.isOpenSourceSelected() } answers { openSourceSelected } @@ -181,6 +195,24 @@ class SearchViewModelV2Test { 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 @@ -275,6 +307,24 @@ 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 `on suggestion selected delegates to search submission`() = runTest { playStoreSelected = true @@ -306,6 +356,15 @@ class SearchViewModelV2Test { 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)) + } + private fun advanceDebounce() { mainCoroutineRule.testDispatcher.scheduler.advanceTimeBy(DEBOUNCE_MS) mainCoroutineRule.testDispatcher.scheduler.runCurrent() @@ -315,6 +374,19 @@ 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 fun visibleTabs(): List = buildList { if (playStoreSelected) add(SearchTabType.COMMON_APPS) if (openSourceSelected) add(SearchTabType.OPEN_SOURCE) @@ -323,6 +395,27 @@ class SearchViewModelV2Test { private fun buildViewModel() { stores = buildStores() - viewModel = SearchViewModelV2(suggestionSource, preference, searchPagingRepository, 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 fun sampleApp(name: String) = Application( + name = name, + _id = name, + ) } -- GitLab From 5634ecf5fac9d690219fcd6e98f3c235672621cc Mon Sep 17 00:00:00 2001 From: Fahim Masud Choudhury Date: Thu, 22 Jan 2026 21:05:09 +0600 Subject: [PATCH 3/4] feat: implement pagination for PlayStore search - Add `PlayStorePagingSource` and `PlayStorePagingRepository` to handle infinite scrolling for PlayStore apps - Integrate Play Store paging flow into `SearchViewModelV2` and `SearchResultsContent` - Create `PagingPlayStoreResultList` composable for displaying Play Store items - Centralize icon URL generation in `Application` model and refactor adapters to use it - Introduce `searchVersion` to properly reset scroll state on new search queries --- .../data/search/PlayStorePagingRepository.kt | 37 ++++++++++------ .../apps/data/search/PlayStorePagingSource.kt | 12 ++---- .../e/apps/data/search/PlayStoreWebSearch.kt | 30 +++++++++++++ .../data/search/PlayStoreWebSearchImpl.kt | 42 +++++++++++++++++++ .../e/apps/di/SearchPagingModule.kt | 21 ++++------ .../components/SearchResultsContent.kt | 4 ++ 6 files changed, 111 insertions(+), 35 deletions(-) create mode 100644 app/src/main/java/foundation/e/apps/data/search/PlayStoreWebSearch.kt create mode 100644 app/src/main/java/foundation/e/apps/data/search/PlayStoreWebSearchImpl.kt 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 index 95e7e9327..aefa7c6b6 100644 --- a/app/src/main/java/foundation/e/apps/data/search/PlayStorePagingRepository.kt +++ b/app/src/main/java/foundation/e/apps/data/search/PlayStorePagingRepository.kt @@ -22,29 +22,40 @@ import androidx.paging.Pager import androidx.paging.PagingConfig import androidx.paging.PagingData import com.aurora.gplayapi.data.models.App -import foundation.e.apps.data.playstore.utils.GPlayHttpClient import kotlinx.coroutines.flow.Flow import javax.inject.Inject import javax.inject.Singleton @Singleton class PlayStorePagingRepository @Inject constructor( - private val gPlayHttpClient: GPlayHttpClient, + private val playStoreWebSearch: PlayStoreWebSearch, ) { fun playStoreSearch(query: String, pageSize: Int): Flow> { return Pager( - config = PagingConfig( - pageSize = pageSize, - enablePlaceholders = false, - prefetchDistance = 2 - ), - pagingSourceFactory = { - PlayStorePagingSource( - query = query, - gPlayHttpClient = gPlayHttpClient, - ) - } + 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 index d30ce0552..a8f5d47c9 100644 --- a/app/src/main/java/foundation/e/apps/data/search/PlayStorePagingSource.kt +++ b/app/src/main/java/foundation/e/apps/data/search/PlayStorePagingSource.kt @@ -22,8 +22,6 @@ import androidx.paging.PagingSource import androidx.paging.PagingState import com.aurora.gplayapi.data.models.App import com.aurora.gplayapi.data.models.StreamCluster -import com.aurora.gplayapi.helpers.web.WebSearchHelper -import foundation.e.apps.data.playstore.utils.GPlayHttpClient import foundation.e.apps.data.playstore.utils.GplayHttpRequestException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -33,11 +31,9 @@ private const val INITIAL_PAGE = 1 class PlayStorePagingSource( private val query: String, - gPlayHttpClient: GPlayHttpClient, + private val playStoreWebSearch: PlayStoreWebSearch, ) : PagingSource() { - private val webSearchHelper = WebSearchHelper().using(gPlayHttpClient) - private var nextBundleUrl: String? = null private val nextStreamUrls = mutableSetOf() @@ -88,7 +84,7 @@ class PlayStorePagingSource( } private suspend fun loadFirstPage(): List = withContext(Dispatchers.IO) { - val bundle = webSearchHelper.searchResults(query) + val bundle = playStoreWebSearch.searchResults(query) nextBundleUrl = bundle.streamNextPageUrl.takeIf { it.isNotBlank() } if (!bundle.hasCluster()) { @@ -105,13 +101,13 @@ class PlayStorePagingSource( nextStreamUrls.clear() pendingStreamUrls.flatMap { streamUrl -> - val cluster = webSearchHelper.nextStreamCluster(query, streamUrl) + val cluster = playStoreWebSearch.nextStreamCluster(query, streamUrl) listOf(cluster).collectApplications() } } !nextBundleUrl.isNullOrBlank() -> { - val bundle = webSearchHelper.nextStreamBundle(query, nextBundleUrl!!) + val bundle = playStoreWebSearch.nextStreamBundle(query, nextBundleUrl!!) nextBundleUrl = bundle.streamNextPageUrl.takeIf { it.isNotBlank() } bundle.streamClusters.values.collectApplications() 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 000000000..672fcb5d2 --- /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 000000000..ffdceb68b --- /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/di/SearchPagingModule.kt b/app/src/main/java/foundation/e/apps/di/SearchPagingModule.kt index a4a0659d2..54d3f2519 100644 --- a/app/src/main/java/foundation/e/apps/di/SearchPagingModule.kt +++ b/app/src/main/java/foundation/e/apps/di/SearchPagingModule.kt @@ -20,12 +20,11 @@ package foundation.e.apps.di import dagger.Binds import dagger.Module -import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent -import foundation.e.apps.data.playstore.utils.GPlayHttpClient import foundation.e.apps.data.search.CleanApkSearchPagingRepository -import foundation.e.apps.data.search.PlayStorePagingRepository +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 @@ -38,15 +37,9 @@ abstract class SearchPagingModule { impl: CleanApkSearchPagingRepository ): SearchPagingRepository - companion object { - @Provides - @Singleton - fun providePlayStorePagingRepository( - gPlayHttpClient: GPlayHttpClient - ): PlayStorePagingRepository { - return PlayStorePagingRepository( - gPlayHttpClient = gPlayHttpClient - ) - } - } + @Binds + @Singleton + abstract fun bindPlayStoreWebSearch( + impl: PlayStoreWebSearchImpl + ): PlayStoreWebSearch } 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 03adc5786..7146f0576 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 @@ -204,9 +204,11 @@ private fun PagingPlayStoreResultList( } val loadState = lazyItems.loadState + val errorState = loadState.refresh as? LoadState.Error ?: loadState.prepend as? LoadState.Error ?: loadState.append as? LoadState.Error + val isRefreshing = loadState.refresh is LoadState.Loading val isAppending = loadState.append is LoadState.Loading val isError = errorState != null @@ -315,9 +317,11 @@ private fun PagingSearchResultList( } val loadState = lazyItems.loadState + val errorState = loadState.refresh as? LoadState.Error ?: loadState.prepend as? LoadState.Error ?: loadState.append as? LoadState.Error + val isRefreshing = loadState.refresh is LoadState.Loading val isAppending = loadState.append is LoadState.Loading val isError = errorState != null -- GitLab From 3941f5c507b56109bfddf84225d3f65be4511155 Mon Sep 17 00:00:00 2001 From: Fahim Masud Choudhury Date: Thu, 22 Jan 2026 22:48:33 +0600 Subject: [PATCH 4/4] test: add tests for PlayStore search paging Add unit tests for Play Store paging source/repository, SearchViewModelV2 paging behavior, and icon URL resolution. Fix SearchResultsContent compose tests to align with the updated API and locale-aware rating display. --- .../components/SearchResultsContentTest.kt | 10 +- .../data/application/data/ApplicationTest.kt | 44 +++ .../search/PlayStorePagingRepositoryTest.kt | 48 ++++ .../data/search/PlayStorePagingSourceTest.kt | 252 ++++++++++++++++++ .../ui/search/v2/SearchViewModelV2Test.kt | 101 +++++++ 5 files changed, 453 insertions(+), 2 deletions(-) create mode 100644 app/src/test/java/foundation/e/apps/data/search/PlayStorePagingRepositoryTest.kt create mode 100644 app/src/test/java/foundation/e/apps/data/search/PlayStorePagingSourceTest.kt diff --git a/app/src/androidTest/java/foundation/e/apps/ui/compose/components/SearchResultsContentTest.kt b/app/src/androidTest/java/foundation/e/apps/ui/compose/components/SearchResultsContentTest.kt index 3d11cba97..6b7ae14eb 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 @@ -49,6 +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 java.util.Locale @RunWith(AndroidJUnit4::class) class SearchResultsContentTest { @@ -103,6 +104,7 @@ class SearchResultsContentTest { selectedTab = selectedTab, fossItems = fossItems, pwaItems = pwaItems, + searchVersion = 0, getScrollPosition = { null }, onScrollPositionChange = { _, _, _ -> }, onTabSelect = { tab -> @@ -129,6 +131,8 @@ 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), @@ -164,10 +168,10 @@ class SearchResultsContentTest { ) composeRule.onNodeWithText("com.example.rated").assertIsDisplayed() - composeRule.onNodeWithText("4.4").assertIsDisplayed() + composeRule.onNodeWithText(expectedRating).assertIsDisplayed() composeRule.onNodeWithText(openLabel).assertIsDisplayed() composeRule.onNodeWithText(notAvailable).assertIsDisplayed() - composeRule.onAllNodesWithText("4.9").assertCountEquals(0) + composeRule.onAllNodesWithText(unexpectedRating).assertCountEquals(0) } @Test @@ -251,6 +255,7 @@ class SearchResultsContentTest { selectedTab: SearchTabType, fossPagingData: PagingData, pwaPagingData: PagingData? = null, + searchVersion: Int = 0, ) { composeRule.setContent { val fossItems = remember { flowOf(fossPagingData) }.collectAsLazyPagingItems() @@ -265,6 +270,7 @@ class SearchResultsContentTest { selectedTab = selectedTab, fossItems = fossItems, pwaItems = pwaItems, + searchVersion = searchVersion, getScrollPosition = { null }, onScrollPositionChange = { _, _, _ -> }, onTabSelect = {}, 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 63e03495c..c47d52642 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/search/PlayStorePagingRepositoryTest.kt b/app/src/test/java/foundation/e/apps/data/search/PlayStorePagingRepositoryTest.kt new file mode 100644 index 000000000..c413e746d --- /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 000000000..c61929cef --- /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 56b9cfbee..7e4e7aa1b 100644 --- a/app/src/test/java/foundation/e/apps/ui/search/v2/SearchViewModelV2Test.kt +++ b/app/src/test/java/foundation/e/apps/ui/search/v2/SearchViewModelV2Test.kt @@ -20,7 +20,9 @@ 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 @@ -325,6 +327,57 @@ class SearchViewModelV2Test { 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 @@ -365,6 +418,27 @@ class SearchViewModelV2Test { 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() @@ -387,6 +461,19 @@ class SearchViewModelV2Test { return differ.snapshot().items } + private suspend fun collectPlayStoreApps(pagingData: PagingData): List { + val differ = AsyncPagingDataDiffer( + diffCallback = PlayStoreAppDiffUtil(), + updateCallback = NoopListCallback(), + mainDispatcher = mainCoroutineRule.testDispatcher, + workerDispatcher = mainCoroutineRule.testDispatcher + ) + + differ.submitData(pagingData) + mainCoroutineRule.testDispatcher.scheduler.advanceUntilIdle() + return differ.snapshot().items + } + private fun visibleTabs(): List = buildList { if (playStoreSelected) add(SearchTabType.COMMON_APPS) if (openSourceSelected) add(SearchTabType.OPEN_SOURCE) @@ -414,8 +501,22 @@ class SearchViewModelV2Test { override fun onChanged(position: Int, count: Int, payload: Any?) = Unit } + private class PlayStoreAppDiffUtil : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: App, newItem: App): Boolean = + oldItem.packageName == newItem.packageName + + override fun areContentsTheSame(oldItem: App, newItem: App): Boolean = + oldItem == newItem + } + private fun sampleApp(name: String) = Application( name = name, _id = name, ) + + private fun samplePlayStoreApp(name: String): App { + val app = mockk(relaxed = true) + every { app.packageName } returns name + return app + } } -- GitLab