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

Commit fbf6bae4 authored by Fahim M. Choudhury's avatar Fahim M. Choudhury
Browse files

Merge branch '3683-add-cleanapk-and-gplay-pagination' into 'main'

feat: add pagination in CleanAPK and PlayStore search results

See merge request !676
parents 9789864e 3941f5c5
Loading
Loading
Loading
Loading
Loading
+2 −0
Original line number Diff line number Diff line
@@ -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
+197 −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,7 @@ import foundation.e.apps.data.enums.Source
import foundation.e.apps.data.enums.Status
import foundation.e.apps.ui.compose.theme.AppTheme
import foundation.e.apps.ui.search.v2.SearchTabType
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import java.util.Locale

@RunWith(AndroidJUnit4::class)
class SearchResultsContentTest {
@@ -51,62 +58,55 @@ class SearchResultsContentTest {

    @Test
    fun emptyTabs_renderNothing() {
        composeRule.setContent {
            AppTheme(darkTheme = false) {
                Surface(color = MaterialTheme.colorScheme.background) {
                    SearchResultsContent(
        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,
                        searchVersion = 0,
                        getScrollPosition = { null },
                        onScrollPositionChange = { _, _, _ -> },
                        onTabSelect = { tab ->
                            selectedTab = tab
                            selectedTabs.add(tab)
@@ -116,17 +116,14 @@ class SearchResultsContentTest {
            }
        }

        composeRule.onNodeWithText("Common App")
            .assertIsDisplayed()
        composeRule.onNodeWithText(openSourceLabel)
            .performClick()

        composeRule.onNodeWithText("Open App").assertIsDisplayed()
        composeRule.onNodeWithText(openSourceLabel).assertIsDisplayed()
        composeRule.onNodeWithText(pwaLabel).performClick()
        composeRule.waitForIdle()

        composeRule.onNodeWithText("Open App")
            .assertIsDisplayed()
        composeRule.onNodeWithText("PWA App").assertIsDisplayed()
        composeRule.runOnIdle {
            assertTrue(selectedTabs.contains(SearchTabType.OPEN_SOURCE))
            assertTrue(selectedTabs.contains(SearchTabType.PWA))
        }
    }

@@ -134,15 +131,14 @@ 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)

        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 +164,134 @@ class SearchResultsContentTest {
                        status = Status.UPDATABLE,
                    ),
                )
                        ),
                        onTabSelect = {},
            )
        )

        composeRule.onNodeWithText("com.example.rated").assertIsDisplayed()
        composeRule.onNodeWithText(expectedRating).assertIsDisplayed()
        composeRule.onNodeWithText(openLabel).assertIsDisplayed()
        composeRule.onNodeWithText(notAvailable).assertIsDisplayed()
        composeRule.onAllNodesWithText(unexpectedRating).assertCountEquals(0)
    }

    @Test
    fun refreshLoading_showsShimmer() {
        val pagingData = PagingData.empty<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,
        searchVersion: Int = 0,
    ) {
        composeRule.setContent {
            val fossItems = remember { flowOf(fossPagingData) }.collectAsLazyPagingItems()
            val pwaItems = pwaPagingData?.let {
                remember(it) { flowOf(it) }.collectAsLazyPagingItems()
            }

            AppTheme(darkTheme = false) {
                Surface(color = MaterialTheme.colorScheme.background) {
                    SearchResultsContent(
                        tabs = tabs,
                        selectedTab = selectedTab,
                        fossItems = fossItems,
                        pwaItems = pwaItems,
                        searchVersion = searchVersion,
                        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
    }
+24 −24
Original line number Diff line number Diff line
@@ -66,7 +66,7 @@ interface CleanApkRetrofit {
        @Query("keyword") keyword: String,
        @Query("source") source: String = APP_SOURCE_FOSS,
        @Query("type") type: String = APP_TYPE_ANY,
        @Query("nres") nres: Int = 20,
        @Query("nres") pageSize: Int = 20,
        @Query("page") page: Int = 1,
        @Query("by") by: String? = null,
        @Query("architectures") architectures: List<String>? = null,
+37 −5
Original line number Diff line number Diff line
@@ -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<Application> {
        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
        }
    }

Loading