diff --git a/app/src/main/java/foundation/e/apps/MainActivity.kt b/app/src/main/java/foundation/e/apps/MainActivity.kt index 733b681073fbf578092cd3a4606fb626dca64f58..f095a39d5ee850a885d087c8f116a10e59e8c802 100644 --- a/app/src/main/java/foundation/e/apps/MainActivity.kt +++ b/app/src/main/java/foundation/e/apps/MainActivity.kt @@ -45,8 +45,8 @@ import foundation.e.apps.setup.signin.SignInViewModel import foundation.e.apps.updates.UpdatesNotifier import foundation.e.apps.utils.enums.Status import foundation.e.apps.utils.enums.User -import foundation.e.apps.utils.parentFragment.TimeoutFragment import foundation.e.apps.utils.modules.CommonUtilsModule +import foundation.e.apps.utils.parentFragment.TimeoutFragment import kotlinx.coroutines.launch import java.io.File import java.util.UUID @@ -143,8 +143,11 @@ class MainActivity : AppCompatActivity() { Log.d(TAG, "Timeout validating auth data!") val lastFragment = navHostFragment.childFragmentManager.fragments[0] if (lastFragment is TimeoutFragment) { - Log.d(TAG, "Displaying timeout from MainActivity on fragment: " - + lastFragment::class.java.name) + Log.d( + TAG, + "Displaying timeout from MainActivity on fragment: " + + lastFragment::class.java.name + ) lastFragment.onTimeout() } } diff --git a/app/src/main/java/foundation/e/apps/MainActivityViewModel.kt b/app/src/main/java/foundation/e/apps/MainActivityViewModel.kt index 23083096b838507f09defa2926974db8b0e3c4f9..ec8d4123c4beaed638795d994cf109ee49887f1f 100644 --- a/app/src/main/java/foundation/e/apps/MainActivityViewModel.kt +++ b/app/src/main/java/foundation/e/apps/MainActivityViewModel.kt @@ -18,7 +18,6 @@ package foundation.e.apps -import android.app.Activity import android.content.Context import android.graphics.Bitmap import android.os.Build @@ -41,7 +40,6 @@ import com.google.gson.Gson import dagger.hilt.android.lifecycle.HiltViewModel import foundation.e.apps.api.cleanapk.blockedApps.BlockedAppRepository import foundation.e.apps.api.ecloud.EcloudRepository -import foundation.e.apps.api.fused.FusedAPIImpl import foundation.e.apps.api.fused.FusedAPIRepository import foundation.e.apps.api.fused.data.FusedApp import foundation.e.apps.manager.database.fusedDownload.FusedDownload diff --git a/app/src/main/java/foundation/e/apps/api/JobResult.kt b/app/src/main/java/foundation/e/apps/api/JobResult.kt index 9a57011d25fdc3638f0081dcad095ff1e14aa573..b632a136663c183d970b782fce13445d3ed6680d 100644 --- a/app/src/main/java/foundation/e/apps/api/JobResult.kt +++ b/app/src/main/java/foundation/e/apps/api/JobResult.kt @@ -16,9 +16,9 @@ open class JobResult private constructor(val status: ResultStatus) { * If needed to just pass a single data element with status for API requests, * see the static methods success(), error(), loading() (in companion object). */ - class of1 (val data1: A, status: ResultStatus): JobResult(status) - class of2 (val data1: A, val data2: B, status: ResultStatus): JobResult(status) - class of3 (val data1: A, val data2: B, val data3: C, status: ResultStatus): JobResult(status) + class of1 (val data1: A, status: ResultStatus) : JobResult(status) + class of2 (val data1: A, val data2: B, status: ResultStatus) : JobResult(status) + class of3 (val data1: A, val data2: B, val data3: C, status: ResultStatus) : JobResult(status) var message = "" @@ -29,7 +29,7 @@ open class JobResult private constructor(val status: ResultStatus) { * For non-null return type, directly use of1, of2, of3 ... classes * and directly access data1, data2, data3 ... */ - val data: T? get() = when(this) { + val data: T? get() = when (this) { is of1 -> this.data1 is of2 -> this.data1 is of3 -> this.data1 @@ -46,12 +46,12 @@ open class JobResult private constructor(val status: ResultStatus) { message?.let { this.message = message } } } - fun create(data1: A, data2: B, status: ResultStatus, message: String? = null): of2 { + fun create(data1: A, data2: B, status: ResultStatus, message: String? = null): of2 { return of2(data1, data2, status).apply { message?.let { this.message = message } } } - fun create(data1: A, data2: B, data3: C, status: ResultStatus, message: String? = null): of3 { + fun create(data1: A, data2: B, data3: C, status: ResultStatus, message: String? = null): of3 { return of3(data1, data2, data3, status).apply { message?.let { this.message = message } } @@ -75,4 +75,4 @@ open class JobResult private constructor(val status: ResultStatus) { else JobResult.of1(data, ResultStatus.LOADING) }*/ } -} \ No newline at end of file +} diff --git a/app/src/main/java/foundation/e/apps/api/ResultSupreme.kt b/app/src/main/java/foundation/e/apps/api/ResultSupreme.kt index 444c612285fec542787477361fd700dbe47e085b..1528035137a6ab1d773317698d5071f0669650b3 100644 --- a/app/src/main/java/foundation/e/apps/api/ResultSupreme.kt +++ b/app/src/main/java/foundation/e/apps/api/ResultSupreme.kt @@ -40,7 +40,7 @@ sealed class ResultSupreme { * * @param data End result of processing. */ - class Success(data: T): ResultSupreme() { + class Success(data: T) : ResultSupreme() { init { setData(data) } } @@ -89,7 +89,7 @@ sealed class ResultSupreme { * Data from processing. May be null. */ var data: T? = null - private set + private set /** * A custom string message for logging or displaying to the user. @@ -124,8 +124,8 @@ sealed class ResultSupreme { exception: Exception = Exception(), ): ResultSupreme { val resultObject = when { - status == ResultStatus.OK && data!= null -> Success(data) - status == ResultStatus.TIMEOUT && data!= null -> Timeout(data) + status == ResultStatus.OK && data != null -> Success(data) + status == ResultStatus.TIMEOUT && data != null -> Timeout(data) else -> Error(message, exception) } resultObject.apply { @@ -161,8 +161,10 @@ sealed class ResultSupreme { is Timeout -> ResultStatus.TIMEOUT is Error -> ResultStatus.UNKNOWN } - return create(status, newData, message ?: result.message, - exception ?: result.exception) + return create( + status, newData, message ?: result.message, + exception ?: result.exception + ) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/foundation/e/apps/api/fused/FusedAPIImpl.kt b/app/src/main/java/foundation/e/apps/api/fused/FusedAPIImpl.kt index aac88fa671f1c58045a75cdaa3d332c3e1694884..dc31a00e8d9524f409000420221b7c9872451faf 100644 --- a/app/src/main/java/foundation/e/apps/api/fused/FusedAPIImpl.kt +++ b/app/src/main/java/foundation/e/apps/api/fused/FusedAPIImpl.kt @@ -27,9 +27,12 @@ import com.aurora.gplayapi.data.models.App import com.aurora.gplayapi.data.models.Artwork import com.aurora.gplayapi.data.models.AuthData import com.aurora.gplayapi.data.models.Category +import com.aurora.gplayapi.data.models.StreamBundle +import com.aurora.gplayapi.data.models.StreamCluster import com.aurora.gplayapi.helpers.TopChartsHelper import dagger.hilt.android.qualifiers.ApplicationContext import foundation.e.apps.R +import foundation.e.apps.api.ResultSupreme import foundation.e.apps.api.cleanapk.CleanAPKInterface import foundation.e.apps.api.cleanapk.CleanAPKRepository import foundation.e.apps.api.cleanapk.data.categories.Categories @@ -45,9 +48,9 @@ import foundation.e.apps.manager.database.fusedDownload.FusedDownload import foundation.e.apps.manager.pkg.PkgManagerModule import foundation.e.apps.utils.enums.AppTag import foundation.e.apps.utils.enums.Origin +import foundation.e.apps.utils.enums.ResultStatus import foundation.e.apps.utils.enums.Status import foundation.e.apps.utils.enums.Type -import foundation.e.apps.utils.enums.ResultStatus import foundation.e.apps.utils.modules.CommonUtilsModule.timeoutDurationInMillis import foundation.e.apps.utils.modules.PWAManagerModule import foundation.e.apps.utils.modules.PreferenceManagerModule @@ -273,30 +276,8 @@ class FusedAPIImpl @Inject constructor( fusedDownload.downloadURLList = list } - suspend fun listApps(category: String, browseUrl: String, authData: AuthData): List? { - val preferredApplicationType = preferenceManagerModule.preferredApplicationType() - - if (preferredApplicationType != "any") { - val response = if (preferredApplicationType == "open") { - getOpenSourceAppsResponse(category) - } else { - getPWAAppsResponse(category) - } - response?.apps?.forEach { - it.updateStatus() - it.updateType() - } - return response?.apps - } else { - val listApps = gPlayAPIRepository.listApps(browseUrl, authData) - return listApps.map { app -> - app.transformToFusedApp() - } - } - } - - suspend fun getPWAApps(category: String): Pair, ResultStatus> { - var list = mutableListOf() + suspend fun getPWAApps(category: String): ResultSupreme> { + val list = mutableListOf() val status = runCodeBlockWithTimeout({ val response = getPWAAppsResponse(category) response?.apps?.forEach { @@ -305,10 +286,10 @@ class FusedAPIImpl @Inject constructor( list.add(it) } }) - return Pair(list, status) + return ResultSupreme.create(status, list) } - suspend fun getOpenSourceApps(category: String): Pair, ResultStatus> { + suspend fun getOpenSourceApps(category: String): ResultSupreme> { val list = mutableListOf() val status = runCodeBlockWithTimeout({ val response = getOpenSourceAppsResponse(category) @@ -318,36 +299,54 @@ class FusedAPIImpl @Inject constructor( list.add(it) } }) - return Pair(list, status) + return ResultSupreme.create(status, list) } - suspend fun getPlayStoreApps(browseUrl: String, authData: AuthData): Pair, ResultStatus> { - var list = mutableListOf() + suspend fun getNextStreamBundle( + authData: AuthData, + homeUrl: String, + currentStreamBundle: StreamBundle, + ): ResultSupreme { + var streamBundle = StreamBundle() val status = runCodeBlockWithTimeout({ - list.addAll(gPlayAPIRepository.listApps(browseUrl, authData).map { app -> - app.transformToFusedApp() - }) + streamBundle = gPlayAPIRepository.getNextStreamBundle(authData, homeUrl, currentStreamBundle) }) - return Pair(list, status) + return ResultSupreme.create(status, streamBundle) } - suspend fun getPlayStoreAppCategoryUrls(browseUrl: String, authData: AuthData): List { - return gPlayAPIRepository.listAppCategoryUrls(browseUrl, authData) + suspend fun getAdjustedFirstCluster( + authData: AuthData, + streamBundle: StreamBundle, + pointer: Int = 0, + ): ResultSupreme { + var streamCluster = StreamCluster() + val status = runCodeBlockWithTimeout({ + streamCluster = gPlayAPIRepository.getAdjustedFirstCluster(authData, streamBundle, pointer) + }) + return ResultSupreme.create(status, streamCluster) } - suspend fun getAppsAndNextClusterUrl( - browseUrl: String, - authData: AuthData - ): Triple, String, ResultStatus> { - val appsList = mutableListOf() - var nextUrl = "" + suspend fun getNextStreamCluster( + authData: AuthData, + currentStreamCluster: StreamCluster, + ): ResultSupreme { + var streamCluster = StreamCluster() val status = runCodeBlockWithTimeout({ - val gPlayResult = gPlayAPIRepository.getAppsAndNextClusterUrl(browseUrl, authData) - appsList.addAll(gPlayResult.first.map { app -> app.transformToFusedApp() }) - nextUrl = gPlayResult.second + streamCluster = gPlayAPIRepository.getNextStreamCluster(authData, currentStreamCluster) }) + return ResultSupreme.create(status, streamCluster) + } - return Triple(appsList, nextUrl, status) + suspend fun getPlayStoreApps(browseUrl: String, authData: AuthData): ResultSupreme> { + val list = mutableListOf() + val status = runCodeBlockWithTimeout({ + list.addAll( + gPlayAPIRepository.listApps(browseUrl, authData).map { app -> + app.transformToFusedApp() + } + ) + }) + return ResultSupreme.create(status, list) } suspend fun getApplicationDetails( @@ -450,6 +449,39 @@ class FusedAPIImpl @Inject constructor( return Pair(fusedAppList, status) } + /** + * Filter out apps which are restricted, whose details cannot be fetched. + * If an app is restricted, we do try to fetch the app details inside a + * try-catch block. If that fails, we remove the app, else we keep it even + * if it is restricted. + * + * Popular example: "com.skype.m2" + * + * Issue: https://gitlab.e.foundation/e/backlog/-/issues/5174 + * Issue: https://gitlab.e.foundation/e/backlog/-/issues/5131 [2] + */ + suspend fun filterRestrictedGPlayApps( + authData: AuthData, + appList: List, + ): ResultSupreme> { + val filteredFusedApps = mutableListOf() + val status = runCodeBlockWithTimeout({ + appList.forEach { + if (it.restriction != Constants.Restriction.NOT_RESTRICTED) { + try { + gPlayAPIRepository.getAppDetails(it.packageName, authData)?.let { app -> + filteredFusedApps.add(app.transformToFusedApp()) + } + } catch (e: Exception) {} + } else { + filteredFusedApps.add(it.transformToFusedApp()) + } + } + }) + + return ResultSupreme.create(status, filteredFusedApps) + } + suspend fun getApplicationDetails( id: String, packageName: String, @@ -457,7 +489,7 @@ class FusedAPIImpl @Inject constructor( origin: Origin ): Pair { - var response : FusedApp? = null + var response: FusedApp? = null val status = runCodeBlockWithTimeout({ response = if (origin == Origin.CLEANAPK) { @@ -555,7 +587,6 @@ class FusedAPIImpl @Inject constructor( apiStatus = ResultStatus.UNKNOWN }) - /* * Try within timeout limit to get PWA categories */ @@ -922,7 +953,8 @@ class FusedAPIImpl @Inject constructor( originalSize = this.size, appSize = Formatter.formatFileSize(context, this.size), isFree = this.isFree, - price = this.price + price = this.price, + restriction = this.restriction, ) app.updateStatus() return app diff --git a/app/src/main/java/foundation/e/apps/api/fused/FusedAPIRepository.kt b/app/src/main/java/foundation/e/apps/api/fused/FusedAPIRepository.kt index f92123cc05b22f02e9e8a5b7ebbf0b45e4cef713..720e4852681e7c6a76d194d1aaf154123c5517cc 100644 --- a/app/src/main/java/foundation/e/apps/api/fused/FusedAPIRepository.kt +++ b/app/src/main/java/foundation/e/apps/api/fused/FusedAPIRepository.kt @@ -19,14 +19,18 @@ package foundation.e.apps.api.fused import com.aurora.gplayapi.SearchSuggestEntry +import com.aurora.gplayapi.data.models.App import com.aurora.gplayapi.data.models.AuthData import com.aurora.gplayapi.data.models.Category +import com.aurora.gplayapi.data.models.StreamBundle +import com.aurora.gplayapi.data.models.StreamCluster +import foundation.e.apps.api.ResultSupreme import foundation.e.apps.api.fused.data.FusedApp import foundation.e.apps.api.fused.data.FusedCategory import foundation.e.apps.api.fused.data.FusedHome import foundation.e.apps.manager.database.fusedDownload.FusedDownload -import foundation.e.apps.utils.enums.ResultStatus import foundation.e.apps.utils.enums.Origin +import foundation.e.apps.utils.enums.ResultStatus import foundation.e.apps.utils.enums.Status import javax.inject.Inject import javax.inject.Singleton @@ -59,6 +63,13 @@ class FusedAPIRepository @Inject constructor( return fusedAPIImpl.getApplicationDetails(packageNameList, authData, origin) } + suspend fun filterRestrictedGPlayApps( + authData: AuthData, + appList: List, + ): ResultSupreme> { + return fusedAPIImpl.filterRestrictedGPlayApps(authData, appList) + } + suspend fun getApplicationDetails( id: String, packageName: String, @@ -100,16 +111,27 @@ class FusedAPIRepository @Inject constructor( return fusedAPIImpl.getSearchResults(query, authData) } - suspend fun listApps(category: String, browseUrl: String, authData: AuthData): List? { - return fusedAPIImpl.listApps(category, browseUrl, authData) + suspend fun getNextStreamBundle( + authData: AuthData, + homeUrl: String, + currentStreamBundle: StreamBundle, + ): ResultSupreme { + return fusedAPIImpl.getNextStreamBundle(authData, homeUrl, currentStreamBundle) } - suspend fun getPlayStoreAppCategoryUrls(browseUrl: String, authData: AuthData): List { - return fusedAPIImpl.getPlayStoreAppCategoryUrls(browseUrl, authData) + suspend fun getAdjustedFirstCluster( + authData: AuthData, + streamBundle: StreamBundle, + pointer: Int = 0, + ): ResultSupreme { + return fusedAPIImpl.getAdjustedFirstCluster(authData, streamBundle, pointer) } - suspend fun getAppsAndNextClusterUrl(browseUrl: String, authData: AuthData): Triple, String, ResultStatus> { - return fusedAPIImpl.getAppsAndNextClusterUrl(browseUrl, authData) + suspend fun getNextStreamCluster( + authData: AuthData, + currentStreamCluster: StreamCluster, + ): ResultSupreme { + return fusedAPIImpl.getNextStreamCluster(authData, currentStreamCluster) } suspend fun getAppsListBasedOnCategory( @@ -117,7 +139,7 @@ class FusedAPIRepository @Inject constructor( browseUrl: String, authData: AuthData, source: String - ): Pair, ResultStatus> { + ): ResultSupreme> { return when (source) { "Open Source" -> fusedAPIImpl.getOpenSourceApps(category) "PWA" -> fusedAPIImpl.getPWAApps(category) diff --git a/app/src/main/java/foundation/e/apps/api/fused/data/FusedApp.kt b/app/src/main/java/foundation/e/apps/api/fused/data/FusedApp.kt index cf911a77e4966235ebe5f0ac73ccfdfd151636d4..2193bfc6de1bcc5e382a9ff235d13a9c29c29981 100644 --- a/app/src/main/java/foundation/e/apps/api/fused/data/FusedApp.kt +++ b/app/src/main/java/foundation/e/apps/api/fused/data/FusedApp.kt @@ -18,6 +18,7 @@ package foundation.e.apps.api.fused.data +import com.aurora.gplayapi.Constants.Restriction import foundation.e.apps.utils.enums.Origin import foundation.e.apps.utils.enums.Status import foundation.e.apps.utils.enums.Type @@ -65,5 +66,18 @@ data class FusedApp( * Issue: https://gitlab.e.foundation/e/backlog/-/issues/5136 */ var permsFromExodus: List = LIST_OF_NULL, - var updatedOn: String = String() + var updatedOn: String = String(), + + /* + * Store restriction from App. + * Issue: https://gitlab.e.foundation/e/backlog/-/issues/5131 [2] + */ + var restriction: Restriction = Restriction.NOT_RESTRICTED, + + /* + * Show a blank app at the end during loading. + * Used when loading apps of a category. + * Issue: https://gitlab.e.foundation/e/backlog/-/issues/5131 [2] + */ + var isPlaceHolder: Boolean = false, ) diff --git a/app/src/main/java/foundation/e/apps/api/gplay/GPlayAPIImpl.kt b/app/src/main/java/foundation/e/apps/api/gplay/GPlayAPIImpl.kt index e4b8fc65e0abb564052ee751b61e3b7bb6d8b883..254b28a80b56cb1a3427ed047c5d9f873d737a71 100644 --- a/app/src/main/java/foundation/e/apps/api/gplay/GPlayAPIImpl.kt +++ b/app/src/main/java/foundation/e/apps/api/gplay/GPlayAPIImpl.kt @@ -78,8 +78,13 @@ class GPlayAPIImpl @Inject constructor( suspend fun validateAuthData(authData: AuthData): Boolean { var validity: Boolean withContext(Dispatchers.IO) { - val authValidator = AuthValidator(authData).using(gPlayHttpClient) - validity = authValidator.isValid() + validity = try { + val authValidator = AuthValidator(authData).using(gPlayHttpClient) + authValidator.isValid() + } catch (e: Exception) { + e.printStackTrace() + false + } } return validity } @@ -172,69 +177,105 @@ class GPlayAPIImpl @Inject constructor( return categoryList } - /** - * Get list of "clusterBrowseUrl" which can be used to get [StreamCluster] objects which - * have "clusterNextPageUrl" to get subsequent [StreamCluster] objects. + /* + * Get StreamBundle, either from the homeUrl of a category, + * or from the current StreamBundle's next url. * - * * -- browseUrl - * | - * StreamBundle 1 (streamNextPageUrl points to StreamBundle 2) - * clusterBrowseUrl 1 -> clusterNextPageUrl 1.1 -> clusterNextPageUrl -> 1.2 .... - * clusterBrowseUrl 2 -> clusterNextPageUrl 2.1 -> clusterNextPageUrl -> 2.2 .... - * clusterBrowseUrl 3 -> clusterNextPageUrl 3.1 -> clusterNextPageUrl -> 3.2 .... - * StreamBundle 2 - * clusterBroseUrl 4 -> ... - * clusterBroseUrl 5 -> ... + * This function will also be used to fetch the next StreamBundle after + * all StreamCluster's in the current StreamBundle is iterated over. * - * This function returns the clusterBrowseUrls 1,2,3,4,5... + * Issue: https://gitlab.e.foundation/e/backlog/-/issues/5131 [2] */ - suspend fun listAppCategoryUrls(browseUrl: String, authData: AuthData): List { - val urlList = mutableListOf() - - withContext(Dispatchers.IO) { - supervisorScope { - - val categoryHelper = CategoryHelper(authData).using(gPlayHttpClient) - - var streamBundle: StreamBundle - var nextStreamBundleUrl = browseUrl - - do { - streamBundle = categoryHelper.getSubCategoryBundle(nextStreamBundleUrl) - val streamClusters = streamBundle.streamClusters.values - - urlList.addAll(streamClusters.map { it.clusterBrowseUrl }) - nextStreamBundleUrl = streamBundle.streamNextPageUrl - } while (nextStreamBundleUrl.isNotBlank()) + suspend fun getNextStreamBundle( + authData: AuthData, + homeUrl: String, + currentStreamBundle: StreamBundle, + ): StreamBundle { + return withContext(Dispatchers.IO) { + val categoryHelper = CategoryHelper(authData).using(gPlayHttpClient) + if (currentStreamBundle.streamClusters.isEmpty()) { + categoryHelper.getSubCategoryBundle(homeUrl) + } else { + categoryHelper.getSubCategoryBundle(currentStreamBundle.streamNextPageUrl) } } - - return urlList.distinct().filter { it.isNotBlank() } } /** - * Accept a [browseUrl] of type "clusterBrowseUrl" or "clusterNextPageUrl". - * Fetch a StreamCluster from the [browseUrl] and return pair of: - * - List od apps to display. - * - String url "clusterNextPageUrl" pointing to next StreamCluster. This can be blank (not null). + * Get first StreamCluster of a StreamBundle. + * + * Ideally it would just be streamBundle.streamClusters[[pointer]], but in case the StreamCluster + * does not have a next url, we need to get a StreamCluster which has a clusterNextPageUrl. + * + * This does not always operate on zeroth StreamCluster of [streamBundle]. + * A StreamBundle can have many StreamClusters, each of the individual StreamCluster can point + * to completely different StreamClusters. + * + * StreamBundle 1 (streamNextPageUrl points to StreamBundle 2) + * StreamCluster 1 -> StreamCluster 1.1 -> StreamCluster 1.2 .... + * StreamCluster 2 -> StreamCluster 2.1 -> StreamCluster 2.2 .... + * StreamCluster 3 -> StreamCluster 3.1 -> StreamCluster 3.2 .... + * + * Here [pointer] refers to the position of StreamCluster 1, 2, 3.... but not 1.1, 2.1 .... + * The subsequent clusters (1.1, 1.2, .. 2.1 ..) are accessed by [getNextStreamCluster]. + * + * Issue: https://gitlab.e.foundation/e/backlog/-/issues/5131 [2] */ - suspend fun getAppsAndNextClusterUrl(browseUrl: String, authData: AuthData): Pair, String> { - val streamCluster: StreamCluster - withContext(Dispatchers.IO) { - supervisorScope { - val streamHelper = StreamHelper(authData).using(gPlayHttpClient) - val browseResponse = streamHelper.getBrowseStreamResponse(browseUrl) - - streamCluster = if (browseResponse.contentsUrl.isNotEmpty()) { - streamHelper.getNextStreamCluster(browseResponse.contentsUrl) - } else if (browseResponse.hasBrowseTab()) { - streamHelper.getNextStreamCluster(browseResponse.browseTab.listUrl) - } else { - StreamCluster() + suspend fun getAdjustedFirstCluster( + authData: AuthData, + streamBundle: StreamBundle, + pointer: Int = 0, + ): StreamCluster { + return withContext(Dispatchers.IO) { + val clusterSize = streamBundle.streamClusters.size + if (clusterSize != 0 && pointer < clusterSize && pointer >= 0) { + streamBundle.streamClusters.values.toList()[pointer].run { + if (hasNext()) { + /* + * If zeroth StreamCluster's next url is not blank, return it. + */ + return@withContext this + } else { + /* + * Try fetching a StreamCluster whose next url is not blank. + * Logic taken from Aurora Store code. + */ + val streamHelper = StreamHelper(authData).using(gPlayHttpClient) + val browseResponse = streamHelper.getBrowseStreamResponse(this.clusterBrowseUrl) + if (browseResponse.contentsUrl.isNotEmpty()) { + return@withContext streamHelper.getNextStreamCluster(browseResponse.contentsUrl) + } else if (browseResponse.hasBrowseTab()) { + return@withContext streamHelper.getNextStreamCluster(browseResponse.browseTab.listUrl) + } + } } } + + /* + * If nothing works return blank StreamCluster. + */ + StreamCluster() + } + } + + /* + * Get next StreamCluster from currentNextPageUrl. + * This method is to be called when the scrollview reaches the bottom. + * + * Issue: https://gitlab.e.foundation/e/backlog/-/issues/5131 [2] + */ + suspend fun getNextStreamCluster( + authData: AuthData, + currentStreamCluster: StreamCluster, + ): StreamCluster { + return withContext(Dispatchers.IO) { + if (currentStreamCluster.hasNext()) { + val streamHelper = StreamHelper(authData).using(gPlayHttpClient) + streamHelper.getNextStreamCluster(currentStreamCluster.clusterNextPageUrl) + } else { + StreamCluster() + } } - return Pair(streamCluster.clusterAppList, streamCluster.clusterNextPageUrl) } suspend fun listApps(browseUrl: String, authData: AuthData): List { diff --git a/app/src/main/java/foundation/e/apps/api/gplay/GPlayAPIRepository.kt b/app/src/main/java/foundation/e/apps/api/gplay/GPlayAPIRepository.kt index 18f37ba54dcbe0dbc41ae2373e5d3ceea2cad485..737f206a1dc1f75356c415d51e8338a5197ddc82 100644 --- a/app/src/main/java/foundation/e/apps/api/gplay/GPlayAPIRepository.kt +++ b/app/src/main/java/foundation/e/apps/api/gplay/GPlayAPIRepository.kt @@ -23,6 +23,8 @@ import com.aurora.gplayapi.data.models.App import com.aurora.gplayapi.data.models.AuthData import com.aurora.gplayapi.data.models.Category import com.aurora.gplayapi.data.models.File +import com.aurora.gplayapi.data.models.StreamBundle +import com.aurora.gplayapi.data.models.StreamCluster import com.aurora.gplayapi.helpers.TopChartsHelper import javax.inject.Inject @@ -79,15 +81,30 @@ class GPlayAPIRepository @Inject constructor( return gPlayAPIImpl.getCategoriesList(type, authData) } - suspend fun listApps(browseUrl: String, authData: AuthData): List { - return gPlayAPIImpl.listApps(browseUrl, authData) + suspend fun getNextStreamBundle( + authData: AuthData, + homeUrl: String, + currentStreamBundle: StreamBundle, + ): StreamBundle { + return gPlayAPIImpl.getNextStreamBundle(authData, homeUrl, currentStreamBundle) + } + + suspend fun getAdjustedFirstCluster( + authData: AuthData, + streamBundle: StreamBundle, + pointer: Int = 0, + ): StreamCluster { + return gPlayAPIImpl.getAdjustedFirstCluster(authData, streamBundle, pointer) } - suspend fun listAppCategoryUrls(browseUrl: String, authData: AuthData): List { - return gPlayAPIImpl.listAppCategoryUrls(browseUrl, authData) + suspend fun getNextStreamCluster( + authData: AuthData, + currentStreamCluster: StreamCluster, + ): StreamCluster { + return gPlayAPIImpl.getNextStreamCluster(authData, currentStreamCluster) } - suspend fun getAppsAndNextClusterUrl(browseUrl: String, authData: AuthData): Pair, String> { - return gPlayAPIImpl.getAppsAndNextClusterUrl(browseUrl, authData) + suspend fun listApps(browseUrl: String, authData: AuthData): List { + return gPlayAPIImpl.listApps(browseUrl, authData) } } diff --git a/app/src/main/java/foundation/e/apps/application/ApplicationFragment.kt b/app/src/main/java/foundation/e/apps/application/ApplicationFragment.kt index 7fcfed4a5d1babb248aaf6653edbc6edabbf5dc1..08db545a15dc26eab0a17d5551883c384cba02b5 100644 --- a/app/src/main/java/foundation/e/apps/application/ApplicationFragment.kt +++ b/app/src/main/java/foundation/e/apps/application/ApplicationFragment.kt @@ -60,9 +60,9 @@ import foundation.e.apps.utils.enums.Origin import foundation.e.apps.utils.enums.ResultStatus import foundation.e.apps.utils.enums.Status import foundation.e.apps.utils.enums.User -import foundation.e.apps.utils.parentFragment.TimeoutFragment import foundation.e.apps.utils.modules.CommonUtilsModule.LIST_OF_NULL import foundation.e.apps.utils.modules.PWAManagerModule +import foundation.e.apps.utils.parentFragment.TimeoutFragment import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import javax.inject.Inject diff --git a/app/src/main/java/foundation/e/apps/applicationlist/ApplicationListFragment.kt b/app/src/main/java/foundation/e/apps/applicationlist/ApplicationListFragment.kt index 258862e53a52b7179392bcfeecc43cd766423bca..21562084e34ade0b233e4f146d7158faeefa05a3 100644 --- a/app/src/main/java/foundation/e/apps/applicationlist/ApplicationListFragment.kt +++ b/app/src/main/java/foundation/e/apps/applicationlist/ApplicationListFragment.kt @@ -43,11 +43,10 @@ import foundation.e.apps.applicationlist.model.ApplicationListRVAdapter import foundation.e.apps.databinding.FragmentApplicationListBinding import foundation.e.apps.manager.download.data.DownloadProgress import foundation.e.apps.manager.pkg.PkgManagerModule -import foundation.e.apps.utils.enums.ResultStatus import foundation.e.apps.utils.enums.Status import foundation.e.apps.utils.enums.User -import foundation.e.apps.utils.parentFragment.TimeoutFragment import foundation.e.apps.utils.modules.PWAManagerModule +import foundation.e.apps.utils.parentFragment.TimeoutFragment import kotlinx.coroutines.launch import javax.inject.Inject @@ -90,7 +89,7 @@ class ApplicationListFragment : TimeoutFragment(R.layout.fragment_application_li private fun observeDownloadList() { mainActivityViewModel.downloadList.observe(viewLifecycleOwner) { list -> - val appList = viewModel.appListLiveData.value?.first?.toMutableList() ?: emptyList() + val appList = viewModel.appListLiveData.value?.data?.toMutableList() ?: emptyList() appList.let { mainActivityViewModel.updateStatusOfFusedApps(it, list) } @@ -99,7 +98,7 @@ class ApplicationListFragment : TimeoutFragment(R.layout.fragment_application_li * Done in one line, so that on Ctrl+click on appListLiveData, * we can see that it is being updated here. */ - viewModel.appListLiveData.apply { value = Pair(appList, value?.second) } + viewModel.appListLiveData.apply { value?.setData(appList) } } } @@ -151,15 +150,16 @@ class ApplicationListFragment : TimeoutFragment(R.layout.fragment_application_li } viewModel.appListLiveData.observe(viewLifecycleOwner) { - listAdapter?.setData(it.first) - if (!isDownloadObserverAdded) { - observeDownloadList() - isDownloadObserverAdded = true - } - stopLoadingUI() - if (it.second != ResultStatus.OK) { + if (!it.isSuccess()) { onTimeout() + } else { + listAdapter?.setData(it.data!!) + if (!isDownloadObserverAdded) { + observeDownloadList() + isDownloadObserverAdded = true + } } + stopLoadingUI() } /* @@ -217,10 +217,24 @@ class ApplicationListFragment : TimeoutFragment(R.layout.fragment_application_li override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) { super.onScrollStateChanged(recyclerView, newState) if (!recyclerView.canScrollVertically(1)) { - viewModel.getPlayStoreAppsOnScroll(args.browseUrl, authData) + viewModel.loadMore(authData, args.browseUrl) } } }) + /* + * This listener comes handy in the case where only 2-3 apps are loaded + * in the first cluster. + * In that case, unless the user scrolls, the above listener will not be + * triggered. Setting this onPlaceHolderShow() callback loads new data + * automatically if the initial data is less. + */ + binding.recyclerView.adapter.apply { + if (this is ApplicationListRVAdapter) { + onPlaceHolderShow = { + viewModel.loadMore(authData, args.browseUrl) + } + } + } } appProgressViewModel.downloadProgress.observe(viewLifecycleOwner) { diff --git a/app/src/main/java/foundation/e/apps/applicationlist/ApplicationListViewModel.kt b/app/src/main/java/foundation/e/apps/applicationlist/ApplicationListViewModel.kt index 72a518e18936b5cfe9e6bee3d94d315004b1bd89..0d689ef75e9b25f621b60da1a308689f85d6cd5e 100644 --- a/app/src/main/java/foundation/e/apps/applicationlist/ApplicationListViewModel.kt +++ b/app/src/main/java/foundation/e/apps/applicationlist/ApplicationListViewModel.kt @@ -22,11 +22,13 @@ import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.aurora.gplayapi.data.models.AuthData +import com.aurora.gplayapi.data.models.StreamBundle +import com.aurora.gplayapi.data.models.StreamCluster import dagger.hilt.android.lifecycle.HiltViewModel +import foundation.e.apps.api.ResultSupreme import foundation.e.apps.api.fused.FusedAPIRepository import foundation.e.apps.api.fused.data.FusedApp import foundation.e.apps.utils.enums.Origin -import foundation.e.apps.utils.enums.ResultStatus import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import javax.inject.Inject @@ -36,140 +38,318 @@ class ApplicationListViewModel @Inject constructor( private val fusedAPIRepository: FusedAPIRepository ) : ViewModel() { - val appListLiveData: MutableLiveData, ResultStatus?>> = MutableLiveData() + val appListLiveData: MutableLiveData>> = MutableLiveData() - private var lastBrowseUrl = String() + private var streamBundle = StreamBundle() + private var streamCluster = StreamCluster() - private val playStoreCategoryUrls = mutableListOf() - private var categoryUrlsPointer = 0 + private var clusterPointer = 0 - private var nextClusterUrl = String() + var isLoading = false - fun getPlayStoreAppsOnScroll(browseUrl: String, authData: AuthData) { - viewModelScope.launch { - /* - * Init condition. - * If category urls are empty or browseUrl has changed, get new category urls. - */ - if (playStoreCategoryUrls.isEmpty() || browseUrl != lastBrowseUrl) { - categoryUrlsPointer = 0 - playStoreCategoryUrls.clear() - playStoreCategoryUrls.addAll( - fusedAPIRepository.getPlayStoreAppCategoryUrls( - browseUrl.apply { lastBrowseUrl = this }, - authData - ) - ) - } + /** + * Variable denoting if we can call [getNextStreamCluster] to get a new StreamBundle. + * + * Initially set to true, so that we can get the first StreamBundle. + * Once the first StreamBundle is fetched, this variable value is same + * as [streamBundle].hasNext(). + * + * For more explanation on how [streamBundle] and [streamCluster] work, look at the + * documentation in [getNextDataSet]. + * + * Issue: https://gitlab.e.foundation/e/backlog/-/issues/5131 [2] + */ + private var hasNextStreamBundle = true - /* - * This is the new list that will be set to the adapter. - * Add existing apps now and add additional apps later. - */ - val newList = mutableListOf().apply { - appListLiveData.value?.first?.let { addAll(it) } - } + /** + * Variable denoting if we can call [getNextStreamCluster] to get a new StreamCluster. + * + * Initially set to false so that we get a StreamBundle first, because initially + * [streamCluster] is empty. Once [streamBundle] is fetched and [getAdjustedFirstCluster] + * is called, this variable value is same as [streamCluster].hasNext(). + * + * For more explanation on how [streamBundle] and [streamCluster] work, look at the + * documentation in [getNextDataSet]. + * + * Issue: https://gitlab.e.foundation/e/backlog/-/issues/5131 [2] + */ + private var hasNextStreamCluster = false - /** - * There are four types of urls we are dealing with here. - * - "browseUrl": looks like: homeV2?cat=SOCIAL&c=3 - * - "category urls" or "clusterBrowseUrl": - * Stored in [playStoreCategoryUrls]. looks like: - * getBrowseStream?ecp=ChWiChIIARIGU09DSUFMKgIIB1ICCAE%3D - * getBrowseStream?ecp=CjOiCjAIARIGU09DSUFMGhwKFnJlY3NfdG9waWNfRjkxMjZNYVJ6S1UQOxgDKgIIB1ICCAI%3D - * - "clusterNextPageUrl": looks like: - * getCluster?enpt=CkCC0_-4AzoKMfqegZ0DKwgIEKGz2kgQuMifuAcQ75So0QkQ6Ijz6gwQzvel8QQQprGBmgUQz938owMQyIeljYQwEAcaFaIKEggBEgZTT0NJQUwqAggHUgIIAQ&n=20 - * - "streamNextPageUrl" - not being used in this method. - * - * StreamBundles are obtained from "browseUrls". - * Each StreamBundle can contain StreamClusters, - * (and point to a following StreamBundle with "streamNextPageUrl" - which is not being used here) - * Each StreamCluster contain - * - apps to display - * - a "clusterBrowseUrl" - * - can point to a following StreamCluster with new app data using "clusterNextPageUrl". - * - * -- browseUrl - * | - * StreamBundle 1 (streamNextPageUrl points to StreamBundle 2) - * clusterBrowseUrl 1 -> clusterNextPageUrl 1.1 -> clusterNextPageUrl -> 1.2 .... - * clusterBrowseUrl 2 -> clusterNextPageUrl 2.1 -> clusterNextPageUrl -> 2.2 .... - * clusterBrowseUrl 3 -> clusterNextPageUrl 3.1 -> clusterNextPageUrl -> 3.2 .... - * StreamBundle 2 - * clusterBroseUrl 4 -> ... - * clusterBroseUrl 5 -> ... - * - * [playStoreCategoryUrls] contains all clusterBrowseUrl 1,2,3 as well as 4,5 ... - * - * Hence we need to go over both "clusterBrowseUrl" (i.e. [playStoreCategoryUrls]) - * as well as available "clusterNextPageUrl". - * The [FusedAPIRepository.getPlayStoreAppCategoryUrls] returns "clusterNextPageUrl" - * in its result (along with list of apps from a StreamCluster.) - * - * Case 1: Initially [nextClusterUrl] will be empty. In that case get the first "clusterBrowseUrl". - * Case 2: After fetching first cluster from getAppsAndNextClusterUrl(), - * nextClusterUrl will be set to a valid "clusterNextPageUrl", - * then this block will not run. - * Case 3: If at any point, the return from getAppsAndNextClusterUrl() below does not - * return non-blank "clusterNextPageUrl", then take the next "clusterBrowseUrl" - * from playStoreCategoryUrls. - * Case 4: All the above cases do not run. This means all available data has been fetched. - * - * [nextClusterUrl] can thus take value of "clusterBrowseUrl" as well as "clusterNextPageUrl" - */ - if (nextClusterUrl.isBlank()) { - nextClusterUrl = playStoreCategoryUrls.getOrElse(categoryUrlsPointer++) { String() } - } - - if (nextClusterUrl.isNotBlank()) { - fusedAPIRepository.getAppsAndNextClusterUrl(nextClusterUrl, authData).run { - val existingPackageNames = newList.map { it.package_name } - newList.addAll(first.filter { it.package_name !in existingPackageNames }) - appListLiveData.postValue(Pair(newList, third)) - nextClusterUrl = second // set the next "clusterNextPageUrl" + fun getList(category: String, browseUrl: String, authData: AuthData, source: String) { + if (appListLiveData.value?.data?.isNotEmpty() == true || isLoading) { + return + } + viewModelScope.launch(Dispatchers.IO) { + val appsListData = if (source == "Open Source" || source == "PWA") { + fusedAPIRepository.getAppsListBasedOnCategory( + category, + browseUrl, + authData, + source + ) + } else { + getNextDataSet(authData, browseUrl).apply { + addPlaceHolderAppIfNeeded(this) } } + + appListLiveData.postValue(appsListData) } } - fun getList(category: String, browseUrl: String, authData: AuthData, source: String) { - if (appListLiveData.value?.first?.isNotEmpty() == true) { - return - } - viewModelScope.launch(Dispatchers.IO) { - val appsListData = fusedAPIRepository.getAppsListBasedOnCategory( - category, - browseUrl, - authData, - source - ) - - if (appsListData.second != ResultStatus.OK) { - appListLiveData.postValue(Pair(listOf(), appsListData.second)) - return@launch + /** + * Add a placeholder app at the end if more data can be loaded. + * "Placeholder" app shows a simple progress bar in the RecyclerView, indicating that + * more apps are being loaded. + * + * Note that it mutates the [ResultSupreme] object passed to it. + * + * Issue: https://gitlab.e.foundation/e/backlog/-/issues/5131 [2] + * + * @param result object from [getNextDataSet]. Data of this object will be updated + * if [canLoadMore] is true. + * + * @return true if a placeholder app was added, false otherwise. + */ + private fun addPlaceHolderAppIfNeeded(result: ResultSupreme>): Boolean { + result.apply { + if (isSuccess() && canLoadMore()) { + // Add an empty app at the end if more data can be loaded on scroll + val newData = data!!.toMutableList() + newData.add(FusedApp(isPlaceHolder = true)) + setData(newData) + return true } + } + return false + } - val applicationDetailsWithStatus = if (!source.contentEquals("PWA")) { + fun loadMore(authData: AuthData, browseUrl: String) { + viewModelScope.launch { + if (!isLoading) { + val lastCount: Int = streamCluster.clusterAppList.size + val result = getNextDataSet(authData, browseUrl) + val newCount = streamCluster.clusterAppList.size + appListLiveData.postValue(result) /* - * Optimization: packageNames were not used anywhere else, - * hence moved here. + * Check if a placeholder app is to be added at the end. + * If yes then post the updated result. + * We post this separately as it helps clear any previous placeholder app + * and ensures only a single placeholder app is present at the end of the + * list, and none at the middle of the list. */ - val packageNames = appsListData.first.map { it.package_name } - fusedAPIRepository.getApplicationDetails( - packageNames, authData, - getOrigin(source) - ) - } else { + if (addPlaceHolderAppIfNeeded(result)) { + appListLiveData.postValue(result) + } + /* - * Optimization: Old code was same as the one called above. + * Old count and new count can be same if new StreamCluster has apps which + * are already shown, i.e. with duplicate package names. + * In that case, if we can load more data, we do it from here itself, + * because recyclerview scroll listener will not trigger itself twice + * for the same data. */ - appsListData + if (result.isSuccess() && lastCount == newCount && canLoadMore()) { + loadMore(authData, browseUrl) + } } + } + } - appListLiveData.postValue(applicationDetailsWithStatus) + /** + * Get the first StreamBundle object from the category browseUrl, or the subsequent + * StreamBundle objects from the "streamNextPageUrl" of current [streamBundle]. + * Also resets the [clusterPointer] to 0. + * + * Issue: https://gitlab.e.foundation/e/backlog/-/issues/5131 [2] + * + * @see getNextDataSet + */ + private suspend fun getNextStreamBundle( + authData: AuthData, + browseUrl: String, + ): ResultSupreme { + return fusedAPIRepository.getNextStreamBundle(authData, browseUrl, streamBundle).apply { + if (isValidData()) streamBundle = data!! + hasNextStreamBundle = streamBundle.hasNext() + clusterPointer = 0 } } + /** + * The first StreamCluster inside [streamBundle] may not have a "clusterNextPageUrl". + * This method tries to fix that. + * + * Issue: https://gitlab.e.foundation/e/backlog/-/issues/5131 [2] + * + * @see getNextDataSet + */ + private suspend fun getAdjustedFirstCluster( + authData: AuthData, + ): ResultSupreme { + return fusedAPIRepository.getAdjustedFirstCluster(authData, streamBundle, clusterPointer).apply { + if (isValidData()) addNewClusterData(this.data!!) + } + } + + /** + * Get all subsequent StreamCluster of the current [streamBundle]. + * Accumulate the data in [streamCluster]. + * + * Issue: https://gitlab.e.foundation/e/backlog/-/issues/5131 [2] + * + * @see getNextDataSet + */ + private suspend fun getNextStreamCluster( + authData: AuthData, + ): ResultSupreme { + return fusedAPIRepository.getNextStreamCluster(authData, streamCluster).apply { + if (isValidData()) addNewClusterData(this.data!!) + } + } + + /** + * Method to add clusterAppList of [newCluster] to [streamCluster], + * but properly point to next StreamCluster. + * Also updates [hasNextStreamCluster]. + * + * Issue: https://gitlab.e.foundation/e/backlog/-/issues/5131 [2] + */ + private fun addNewClusterData(newCluster: StreamCluster) { + newCluster.run { + streamCluster.clusterAppList.apply { + val addedList = this + newCluster.clusterAppList + clear() + addAll(addedList.distinctBy { it.packageName }) + } + streamCluster.clusterNextPageUrl = this.clusterNextPageUrl + streamCluster.clusterBrowseUrl = this.clusterBrowseUrl + } + hasNextStreamCluster = newCluster.hasNext() + } + + /** + * This is how the logic works: + * + * StreamBundles are obtained from "browseUrls". + * Each StreamBundle can contain + * - some StreamClusters, + * - point to a following StreamBundle with "streamNextPageUrl" + * (checked by StreamBundle.hasNext()) + * Each StreamCluster contain + * - apps to display + * - a "clusterBrowseUrl" + * - can point to a following StreamCluster with new app data using "clusterNextPageUrl" + * (checked by StreamCluster.hasNext()) + * + * -- browseUrl + * | + * StreamBundle 1 (streamNextPageUrl points to StreamBundle 2) + * StreamCluster 1 -> StreamCluster 1.1 -> StreamCluster 1.2 .... + * StreamCluster 2 -> StreamCluster 2.1 -> StreamCluster 2.2 .... + * StreamCluster 3 -> StreamCluster 3.1 -> StreamCluster 3.2 .... + * StreamBundle 2 + * StreamCluster 4 -> ... + * StreamCluster 5 -> ... + * + * + * - "browseUrl": looks like: homeV2?cat=SOCIAL&c=3 + * - "clusterBrowseUrl" (not used here): looks like: + * getBrowseStream?ecp=ChWiChIIARIGU09DSUFMKgIIB1ICCAE%3D + * getBrowseStream?ecp=CjOiCjAIARIGU09DSUFMGhwKFnJlY3NfdG9waWNfRjkxMjZNYVJ6S1UQOxgDKgIIB1ICCAI%3D + * - "clusterNextPageUrl" (not directly used here): looks like: + * getCluster?enpt=CkCC0_-4AzoKMfqegZ0DKwgIEKGz2kgQuMifuAcQ75So0QkQ6Ijz6gwQzvel8QQQprGBmgUQz938owMQyIeljYQwEAcaFaIKEggBEgZTT0NJQUwqAggHUgIIAQ&n=20 + * + * ========== Working logic ========== + * + * 1. [streamCluster] accumulates all data from all subsequent network calls. + * Its "clusterNextPageUrl" does point to the next StreamCluster, but its "clusterAppList" + * contains accumulated data of all previous network calls. + * + * 2. [streamBundle] is the same value received from [getNextStreamBundle]. + * + * 3. Initially [hasNextStreamCluster] is false, denoting [streamCluster] is empty. + * Initially [clusterPointer] = 0, [streamBundle].streamClusters.size = 0, + * hence 2nd case also does not execute. + * However, initially [hasNextStreamBundle] is true, thus [getNextStreamBundle] is called, + * fetching the first StreamBundle and storing the data in [streamBundle], and getting the first + * StreamCluster data using [getAdjustedFirstCluster]. + * + * NOTE: [getAdjustedFirstCluster] is used to fetch StreamCluster 1, 2, 3 .. in the above + * diagram with help of [clusterPointer]. For subsequent StreamCluster 1.1, 1.2 .. 2.1 .. + * [getNextStreamCluster] is used. + * + * 4. From now onwards, + * - [hasNextStreamBundle] is as good as [streamBundle].hasNext() + * - [hasNextStreamCluster] is as good as [streamCluster].hasNext() + * + * 5.1. When this method is again called when list reaches the end while scrolling on the UI, + * if [hasNextStreamCluster] is true, we will get the next StreamCluster under the current + * StreamBundle object. Once the last StreamCluster is reached, [hasNextStreamCluster] is + * false, we move to the next case. + * + * 5.2. In the step 5.1 we have been traversing along the path StreamCluster 1 -> 1.1 -> 1.2 .. + * Once that path reaches an end, we need to jump to StreamCluster 2 -> 2.1 -> 2.2 .. + * This is achieved by the second condition using [clusterPointer]. We increment the + * pointer and call [getAdjustedFirstCluster] again to start from StreamCluster 2. + * + * 5.3. Once we no longer have any more beginning StreamClusters, i.e + * [clusterPointer] exceeds [streamBundle].streamClusters size, the second condition no + * longer holds. Now we should try to go to a different StreamBundle. + * Using the above diagram, we move to StreamBundle 1 -> 2. + * We check [hasNextStreamBundle]. If that is true, we load the next StreamBundle. + * This also fetches the first StreamCluster of this bundle, thus re-initialising both + * [hasNextStreamCluster] and [hasNextStreamBundle]. + * + * 6. Once we reach the end of all StreamBundles and all StreamClusters, now calling + * this method makes no network calls. + * + * Issue: https://gitlab.e.foundation/e/backlog/-/issues/5131 [2] + */ + private suspend fun getNextDataSet( + authData: AuthData, + browseUrl: String, + ): ResultSupreme> { + isLoading = true + + if (hasNextStreamCluster) { + getNextStreamCluster(authData).run { + if (!isSuccess()) { + return ResultSupreme.replicate(this, listOf()) + } + } + } else if (clusterPointer < streamBundle.streamClusters.size) { + ++clusterPointer + getAdjustedFirstCluster(authData).run { + if (!isSuccess()) { + return ResultSupreme.replicate(this, listOf()) + } + } + } else if (hasNextStreamBundle) { + getNextStreamBundle(authData, browseUrl).run { + if (!isSuccess()) { + return ResultSupreme.replicate(this, listOf()) + } + getAdjustedFirstCluster(authData).run { + if (!isSuccess()) { + return ResultSupreme.replicate(this, listOf()) + } + } + } + } + return fusedAPIRepository.filterRestrictedGPlayApps(authData, streamCluster.clusterAppList) + .apply { + isLoading = false + } + } + + /** + * Function is used to check if we can load more data. + * It is also used to show a loading progress bar at the end of the list. + */ + fun canLoadMore(): Boolean = + hasNextStreamCluster || clusterPointer < streamBundle.streamClusters.size || hasNextStreamBundle + private fun getOrigin(source: String) = if (source.contentEquals("Open Source")) Origin.CLEANAPK else Origin.GPLAY } diff --git a/app/src/main/java/foundation/e/apps/applicationlist/model/ApplicationListRVAdapter.kt b/app/src/main/java/foundation/e/apps/applicationlist/model/ApplicationListRVAdapter.kt index f610b5c3182027d14b2b57c21314c82569a5f963..c6ed486a5bef397dba079989c203b00dad2b40db 100644 --- a/app/src/main/java/foundation/e/apps/applicationlist/model/ApplicationListRVAdapter.kt +++ b/app/src/main/java/foundation/e/apps/applicationlist/model/ApplicationListRVAdapter.kt @@ -25,6 +25,7 @@ import android.view.View import android.view.ViewGroup import android.widget.ImageView import androidx.core.content.ContextCompat +import androidx.core.view.children import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData @@ -79,6 +80,8 @@ class ApplicationListRVAdapter( .setAutoStart(true) .build() + var onPlaceHolderShow: (() -> Unit)? = null + inner class ViewHolder(val binding: ApplicationListItemBinding) : RecyclerView.ViewHolder(binding.root) { var isPurchasedLiveData: LiveData = MutableLiveData() @@ -99,6 +102,26 @@ class ApplicationListRVAdapter( val searchApp = getItem(position) val shimmerDrawable = ShimmerDrawable().apply { setShimmer(shimmer) } + /* + * A placeholder entry is one where we only show a loading progress bar, + * instead of an app entry. + * It is usually done to signify more apps are being loaded at the end of the list. + * + * We hide all view elements other than the circular progress bar. + * + * Issue: https://gitlab.e.foundation/e/backlog/-/issues/5131 [2] + */ + if (searchApp.isPlaceHolder) { + val progressBar = holder.binding.placeholderProgressBar + holder.binding.root.children.forEach { + it.visibility = if (it != progressBar) View.INVISIBLE + else View.VISIBLE + } + onPlaceHolderShow?.invoke() + // Do not process anything else for this entry + return + } + holder.binding.apply { if (searchApp.privacyScore == -1) { hidePrivacyScore() diff --git a/app/src/main/java/foundation/e/apps/home/HomeFragment.kt b/app/src/main/java/foundation/e/apps/home/HomeFragment.kt index a176df2064bc884706d1f473efffb3e999249585..76aba8e899a2fee4d20e13a85eda33570cb283c6 100644 --- a/app/src/main/java/foundation/e/apps/home/HomeFragment.kt +++ b/app/src/main/java/foundation/e/apps/home/HomeFragment.kt @@ -21,7 +21,6 @@ package foundation.e.apps.home import android.os.Bundle import android.view.View import android.widget.ImageView -import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.fragment.app.viewModels import androidx.lifecycle.lifecycleScope @@ -46,9 +45,9 @@ import foundation.e.apps.manager.pkg.PkgManagerModule import foundation.e.apps.utils.enums.ResultStatus import foundation.e.apps.utils.enums.Status import foundation.e.apps.utils.enums.User -import foundation.e.apps.utils.parentFragment.TimeoutFragment import foundation.e.apps.utils.modules.CommonUtilsModule.safeNavigate import foundation.e.apps.utils.modules.PWAManagerModule +import foundation.e.apps.utils.parentFragment.TimeoutFragment import kotlinx.coroutines.launch import javax.inject.Inject diff --git a/app/src/main/java/foundation/e/apps/manager/download/data/DownloadProgressLD.kt b/app/src/main/java/foundation/e/apps/manager/download/data/DownloadProgressLD.kt index b606f3b9b0280624308c098a33b7be0342650c43..ecee78faa8e9b98499d7280d2e1af568163ba49c 100644 --- a/app/src/main/java/foundation/e/apps/manager/download/data/DownloadProgressLD.kt +++ b/app/src/main/java/foundation/e/apps/manager/download/data/DownloadProgressLD.kt @@ -11,7 +11,6 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.cancel import kotlinx.coroutines.delay -import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import javax.inject.Inject import kotlin.coroutines.CoroutineContext diff --git a/app/src/main/java/foundation/e/apps/search/SearchFragment.kt b/app/src/main/java/foundation/e/apps/search/SearchFragment.kt index 3d66b351cbfcb03f26fb16bc09e8e0dac4b725de..732389e54a4ba384c0b3d4af2b5b34003fd58cff 100644 --- a/app/src/main/java/foundation/e/apps/search/SearchFragment.kt +++ b/app/src/main/java/foundation/e/apps/search/SearchFragment.kt @@ -54,8 +54,8 @@ import foundation.e.apps.manager.pkg.PkgManagerModule import foundation.e.apps.utils.enums.ResultStatus import foundation.e.apps.utils.enums.Status import foundation.e.apps.utils.enums.User -import foundation.e.apps.utils.parentFragment.TimeoutFragment import foundation.e.apps.utils.modules.PWAManagerModule +import foundation.e.apps.utils.parentFragment.TimeoutFragment import kotlinx.coroutines.launch import javax.inject.Inject @@ -174,7 +174,6 @@ class SearchFragment : searchViewModel.searchResult.apply { value = Pair(searchList, value?.second) } } - /* * Explanation of double observers in HomeFragment.kt * Modified to check and search only if searchText in not blank, to prevent blank search. diff --git a/app/src/main/java/foundation/e/apps/updates/UpdatesFragment.kt b/app/src/main/java/foundation/e/apps/updates/UpdatesFragment.kt index 6dff83206a63636b17dde0604fdb436652c8ce69..b5b6be8987d24b8d6bc15a8cdd7210f21e344bd2 100644 --- a/app/src/main/java/foundation/e/apps/updates/UpdatesFragment.kt +++ b/app/src/main/java/foundation/e/apps/updates/UpdatesFragment.kt @@ -47,9 +47,9 @@ import foundation.e.apps.updates.manager.UpdatesWorkManager import foundation.e.apps.utils.enums.ResultStatus import foundation.e.apps.utils.enums.Status import foundation.e.apps.utils.enums.User -import foundation.e.apps.utils.parentFragment.TimeoutFragment import foundation.e.apps.utils.modules.CommonUtilsModule.safeNavigate import foundation.e.apps.utils.modules.PWAManagerModule +import foundation.e.apps.utils.parentFragment.TimeoutFragment import kotlinx.coroutines.launch import javax.inject.Inject diff --git a/app/src/main/java/foundation/e/apps/utils/enums/ResultStatus.kt b/app/src/main/java/foundation/e/apps/utils/enums/ResultStatus.kt index f9f3c5de78a99e3a7744e1fead0fd7e984b92b36..6fb9ceebec0a79d18ebcc2bba92fcc7678c7cc26 100644 --- a/app/src/main/java/foundation/e/apps/utils/enums/ResultStatus.kt +++ b/app/src/main/java/foundation/e/apps/utils/enums/ResultStatus.kt @@ -4,4 +4,4 @@ enum class ResultStatus { OK, TIMEOUT, UNKNOWN, -} \ No newline at end of file +} diff --git a/app/src/main/java/foundation/e/apps/utils/parentFragment/TimeoutFragment.kt b/app/src/main/java/foundation/e/apps/utils/parentFragment/TimeoutFragment.kt index 2aa3d485fbbd412223dbf5905af28a710f70a465..7900b23809b5d612cda1a23bf06a87cc3aadfb9b 100644 --- a/app/src/main/java/foundation/e/apps/utils/parentFragment/TimeoutFragment.kt +++ b/app/src/main/java/foundation/e/apps/utils/parentFragment/TimeoutFragment.kt @@ -31,7 +31,7 @@ import foundation.e.apps.R * for network calls exceeding timeout limit. * Issue: https://gitlab.e.foundation/e/backlog/-/issues/5413 */ -abstract class TimeoutFragment(@LayoutRes layoutId: Int): Fragment(layoutId) { +abstract class TimeoutFragment(@LayoutRes layoutId: Int) : Fragment(layoutId) { /* * Alert dialog to show to user if App Lounge times out. @@ -159,17 +159,17 @@ abstract class TimeoutFragment(@LayoutRes layoutId: Int): Fragment(layoutId) { * Set buttons. */ positiveButtonText?.let { - setPositiveButton(it) {_, _ -> + setPositiveButton(it) { _, _ -> positiveButtonBlock?.invoke() } } negativeButtonText?.let { - setNegativeButton(it) {_, _ -> + setNegativeButton(it) { _, _ -> negativeButtonBlock?.invoke() } } neutralButtonText?.let { - setNeutralButton(it) {_, _ -> + setNeutralButton(it) { _, _ -> neutralButtonBlock?.invoke() } } @@ -211,4 +211,4 @@ abstract class TimeoutFragment(@LayoutRes layoutId: Int): Fragment(layoutId) { } catch (_: Exception) {} } } -} \ No newline at end of file +} diff --git a/app/src/main/res/layout/application_list_item.xml b/app/src/main/res/layout/application_list_item.xml index 921a9273f6672ee1633b6206df866525ea8d04ed..31312cde513094d5095ee8b23fcf04b4623e7887 100644 --- a/app/src/main/res/layout/application_list_item.xml +++ b/app/src/main/res/layout/application_list_item.xml @@ -29,6 +29,17 @@ android:clickable="true" android:focusable="true"> + +