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

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

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
parent 231c0ff0
Loading
Loading
Loading
Loading
+191 −91
Original line number Diff line number Diff line
@@ -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(
        val noAppsText = composeRule.activity.getString(R.string.no_apps_found)

        renderSearchResults(
            tabs = emptyList(),
            selectedTab = SearchTabType.COMMON_APPS,
                        resultsByTab = mapOf(
                            SearchTabType.COMMON_APPS to listOf(sampleApp("Hidden App"))
                        ),
                        onTabSelect = {},
            fossPagingData = PagingData.empty(),
        )
                }
            }
        }

        composeRule.onAllNodesWithText("Hidden App")
            .assertCountEquals(0)
        composeRule.onAllNodesWithText(noAppsText).assertCountEquals(0)
    }

    @Test
    fun selectedTabOutsideTabs_renderNothing() {
        composeRule.setContent {
            AppTheme(darkTheme = false) {
                Surface(color = MaterialTheme.colorScheme.background) {
                    SearchResultsContent(
        val noAppsText = composeRule.activity.getString(R.string.no_apps_found)

        renderSearchResults(
            tabs = listOf(SearchTabType.OPEN_SOURCE),
            selectedTab = SearchTabType.COMMON_APPS,
                        resultsByTab = mapOf(
                            SearchTabType.COMMON_APPS to listOf(sampleApp("Missing Tab App"))
                        ),
                        onTabSelect = {},
            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<SearchTabType>()
        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,14 +130,11 @@ class SearchResultsContentTest {
        val notAvailable = composeRule.activity.getString(R.string.not_available)
        val openLabel = composeRule.activity.getString(R.string.open)

        composeRule.setContent {
            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(
        renderSearchResults(
            tabs = listOf(SearchTabType.OPEN_SOURCE),
            selectedTab = SearchTabType.OPEN_SOURCE,
            fossPagingData = pagingData(
                listOf(
                    Application(
                        name = "Rated App",
                        author = "",
@@ -168,24 +160,132 @@ class SearchResultsContentTest {
                        status = Status.UPDATABLE,
                    ),
                )
                        ),
                        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)
    }

    @Test
    fun refreshLoading_showsShimmer() {
        val pagingData = PagingData.empty<Application>(
            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<Application>(
            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()
    }

        composeRule.onNodeWithText("com.example.rated")
            .assertIsDisplayed()
        composeRule.onNodeWithText("4.4")
            .assertIsDisplayed()
        composeRule.onNodeWithText(openLabel)
            .assertIsDisplayed()
        composeRule.onNodeWithText(notAvailable)
    @Test
    fun emptyResults_showsPlaceholder() {
        val pagingData = PagingData.empty<Application>(
            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()
        composeRule.onAllNodesWithText("4.9")
            .assertCountEquals(0)
    }

    private fun renderSearchResults(
        tabs: List<SearchTabType>,
        selectedTab: SearchTabType,
        fossPagingData: PagingData<Application>,
        pwaPagingData: PagingData<Application>? = 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 = tabs,
                        selectedTab = selectedTab,
                        fossItems = fossItems,
                        pwaItems = pwaItems,
                        getScrollPosition = { null },
                        onScrollPositionChange = { _, _, _ -> },
                        onTabSelect = {},
                    )
                }
            }
        }
    }

    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<Application>) = PagingData.from(
        apps,
        sourceLoadStates = loadStates(
            refresh = LoadState.NotLoading(endOfPaginationReached = false)
        )
    )
}
+18 −0
Original line number Diff line number Diff line
@@ -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<Map<String, String>> = 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
    }
+1 −2
Original line number Diff line number Diff line
@@ -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
            )
+50 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2026 e Foundation
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
 *
 */

package foundation.e.apps.data.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<PagingData<App>> {
        return Pager(
            config = PagingConfig(
                pageSize = pageSize,
                enablePlaceholders = false,
                prefetchDistance = 2
            ),
            pagingSourceFactory = {
                PlayStorePagingSource(
                    query = query,
                    gPlayHttpClient = gPlayHttpClient,
                )
            }
        ).flow
    }
}
+138 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2026 e Foundation
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
 *
 */

package foundation.e.apps.data.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<Int, App>() {

    private val webSearchHelper = WebSearchHelper().using(gPlayHttpClient)

    private var nextBundleUrl: String? = null
    private val nextStreamUrls = mutableSetOf<String>()

    override fun getRefreshKey(state: PagingState<Int, App>): 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<Int>): LoadResult<Int, App> {
        val page = params.key ?: INITIAL_PAGE
        return try {
            val data: List<App> = 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<App> = withContext(Dispatchers.IO) {
        val bundle = webSearchHelper.searchResults(query)
        nextBundleUrl = bundle.streamNextPageUrl.takeIf { it.isNotBlank() }

        if (!bundle.hasCluster()) {
            return@withContext emptyList<App>()
        }

        bundle.streamClusters.values.collectApplications()
    }

    private suspend fun loadNextPage(): List<App> = 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<StreamCluster>.collectApplications(): List<App> {
        val apps = mutableListOf<App>()

        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 }
    }
}
Loading