Loading app/src/main/java/foundation/e/apps/api/fused/FusedAPIRepository.kt +85 −39 Original line number Diff line number Diff line Loading @@ -35,6 +35,7 @@ import foundation.e.apps.utils.enums.FilterLevel import foundation.e.apps.utils.enums.Origin import foundation.e.apps.utils.enums.ResultStatus import foundation.e.apps.utils.enums.Status import timber.log.Timber import javax.inject.Inject import javax.inject.Singleton Loading @@ -42,6 +43,45 @@ import javax.inject.Singleton class FusedAPIRepository @Inject constructor( private val fusedAPIImpl: FusedAPIImpl ) { var streamBundle = StreamBundle() private set var streamCluster = StreamCluster() private set var clusterPointer = 0 private set /** * 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] */ var hasNextStreamBundle = true private set /** * 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] */ var hasNextStreamCluster = false private set suspend fun getHomeScreenData(authData: AuthData): Pair<List<FusedHome>, ResultStatus> { return fusedAPIImpl.getHomeScreenData(authData) } Loading Loading @@ -188,6 +228,38 @@ class FusedAPIRepository @Inject constructor( fun isAnyAppInstallStatusChanged(currentList: List<FusedApp>) = fusedAPIImpl.isAnyAppInstallStatusChanged(currentList) suspend fun getAppList( category: String, browseUrl: String, authData: AuthData, source: String ): ResultSupreme<List<FusedApp>> { return if (source == "Open Source" || source == "PWA") { getAppsListBasedOnCategory( category, browseUrl, authData, source ) } else { getNextDataSet(authData, browseUrl).apply { addPlaceHolderAppIfNeeded(this) } } } /** * @return a Pair, * 1. first item is the data * 1. second item is item count is changed or not */ suspend fun loadMore(authData: AuthData, browseUrl: String): Pair<ResultSupreme<List<FusedApp>>, Boolean> { val lastCount: Int = streamCluster.clusterAppList.size val result = getNextDataSet(authData, browseUrl) val newCount = streamCluster.clusterAppList.size return Pair(result, lastCount != newCount) } /** * This is how the logic works: * Loading Loading @@ -267,44 +339,11 @@ class FusedAPIRepository @Inject constructor( * Issue: https://gitlab.e.foundation/e/backlog/-/issues/5131 [2] */ private var streamBundle = StreamBundle() private var streamCluster = StreamCluster() private var clusterPointer = 0 /** * 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 /** * 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 suspend fun getNextDataSet( private suspend fun getNextDataSet( authData: AuthData, browseUrl: String, ): ResultSupreme<List<FusedApp>> { Timber.d("hasNextStreamCluster: $hasNextStreamCluster hasNextStreamBundle: $hasNextStreamBundle clusterPointer: $clusterPointer: streambundleSize: ${streamBundle.streamClusters.size} streamClusterSize: ${streamCluster.clusterAppList.size}") if (hasNextStreamCluster) { getNextStreamCluster(authData).run { if (!isSuccess()) { Loading @@ -330,9 +369,8 @@ class FusedAPIRepository @Inject constructor( } } } return filterRestrictedGPlayApps(authData, streamCluster.clusterAppList).apply { addPlaceHolderAppIfNeeded(this) } Timber.d("===> calling last segment") return filterRestrictedGPlayApps(authData, streamCluster.clusterAppList) } /** Loading @@ -349,7 +387,7 @@ class FusedAPIRepository @Inject constructor( * * @return true if a placeholder app was added, false otherwise. */ private fun addPlaceHolderAppIfNeeded(result: ResultSupreme<List<FusedApp>>): Boolean { fun addPlaceHolderAppIfNeeded(result: ResultSupreme<List<FusedApp>>): Boolean { result.apply { if (isSuccess() && canLoadMore()) { // Add an empty app at the end if more data can be loaded on scroll Loading Loading @@ -441,4 +479,12 @@ class FusedAPIRepository @Inject constructor( */ fun canLoadMore(): Boolean = hasNextStreamCluster || clusterPointer < streamBundle.streamClusters.size || hasNextStreamBundle fun clearData() { streamCluster = StreamCluster() streamBundle = StreamBundle() hasNextStreamBundle = true hasNextStreamCluster = false clusterPointer = 0 } } app/src/main/java/foundation/e/apps/applicationlist/ApplicationListViewModel.kt +37 −267 Original line number Diff line number Diff line Loading @@ -31,6 +31,7 @@ import foundation.e.apps.api.fused.data.FusedApp import foundation.e.apps.utils.enums.Origin import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import timber.log.Timber import javax.inject.Inject @HiltViewModel Loading @@ -40,60 +41,20 @@ class ApplicationListViewModel @Inject constructor( val appListLiveData: MutableLiveData<ResultSupreme<List<FusedApp>>> = MutableLiveData() private var streamBundle = StreamBundle() private var streamCluster = StreamCluster() private var clusterPointer = 0 var isLoading = false /** * 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 /** * 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 fun getList(category: String, browseUrl: String, authData: AuthData, source: String) { Timber.d("===> getlist: $isLoading") if (isLoading) { return } viewModelScope.launch(Dispatchers.IO) { val appsListData = if (source == "Open Source" || source == "PWA") { fusedAPIRepository.getAppsListBasedOnCategory( category, browseUrl, authData, source ) } else { fusedAPIRepository.getNextDataSet(authData, browseUrl).apply { addPlaceHolderAppIfNeeded(this) } isLoading = true fusedAPIRepository.getAppList(category, browseUrl, authData, source).apply { isLoading = false appListLiveData.postValue(this) Timber.d("final result: ${this.data?.size}") } appListLiveData.postValue(appsListData) } } Loading Loading @@ -123,7 +84,7 @@ class ApplicationListViewModel @Inject constructor( */ private fun addPlaceHolderAppIfNeeded(result: ResultSupreme<List<FusedApp>>): Boolean { result.apply { if (isSuccess() && canLoadMore()) { if (isSuccess() && fusedAPIRepository.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)) Loading @@ -136,11 +97,14 @@ class ApplicationListViewModel @Inject constructor( fun loadMore(authData: AuthData, browseUrl: String) { viewModelScope.launch { if (!isLoading) { val lastCount: Int = streamCluster.clusterAppList.size val result = fusedAPIRepository.getNextDataSet(authData, browseUrl) val newCount = streamCluster.clusterAppList.size appListLiveData.postValue(result) if (isLoading) { return@launch } isLoading = true val result = fusedAPIRepository.loadMore(authData, browseUrl) isLoading = false appListLiveData.postValue(result.first!!) /* * Check if a placeholder app is to be added at the end. * If yes then post the updated result. Loading @@ -148,8 +112,8 @@ class ApplicationListViewModel @Inject constructor( * and ensures only a single placeholder app is present at the end of the * list, and none at the middle of the list. */ if (addPlaceHolderAppIfNeeded(result)) { appListLiveData.postValue(result) if (fusedAPIRepository.addPlaceHolderAppIfNeeded(result.first)) { appListLiveData.postValue(result.first!!) } /* Loading @@ -159,210 +123,11 @@ class ApplicationListViewModel @Inject constructor( * because recyclerview scroll listener will not trigger itself twice * for the same data. */ if (result.isSuccess() && lastCount == newCount && canLoadMore()) { if (result.first.isSuccess() && !result.second && fusedAPIRepository.canLoadMore()) { loadMore(authData, browseUrl) } } } } /** * 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<StreamBundle> { 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<StreamCluster> { 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<StreamCluster> { 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<List<FusedApp>> { 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 /** * @return returns true if there is changes in data, otherwise false Loading @@ -374,4 +139,9 @@ class ApplicationListViewModel @Inject constructor( fun hasAnyAppInstallStatusChanged(currentList: List<FusedApp>) = fusedAPIRepository.isAnyAppInstallStatusChanged(currentList) override fun onCleared() { fusedAPIRepository.clearData() super.onCleared() } } Loading
app/src/main/java/foundation/e/apps/api/fused/FusedAPIRepository.kt +85 −39 Original line number Diff line number Diff line Loading @@ -35,6 +35,7 @@ import foundation.e.apps.utils.enums.FilterLevel import foundation.e.apps.utils.enums.Origin import foundation.e.apps.utils.enums.ResultStatus import foundation.e.apps.utils.enums.Status import timber.log.Timber import javax.inject.Inject import javax.inject.Singleton Loading @@ -42,6 +43,45 @@ import javax.inject.Singleton class FusedAPIRepository @Inject constructor( private val fusedAPIImpl: FusedAPIImpl ) { var streamBundle = StreamBundle() private set var streamCluster = StreamCluster() private set var clusterPointer = 0 private set /** * 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] */ var hasNextStreamBundle = true private set /** * 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] */ var hasNextStreamCluster = false private set suspend fun getHomeScreenData(authData: AuthData): Pair<List<FusedHome>, ResultStatus> { return fusedAPIImpl.getHomeScreenData(authData) } Loading Loading @@ -188,6 +228,38 @@ class FusedAPIRepository @Inject constructor( fun isAnyAppInstallStatusChanged(currentList: List<FusedApp>) = fusedAPIImpl.isAnyAppInstallStatusChanged(currentList) suspend fun getAppList( category: String, browseUrl: String, authData: AuthData, source: String ): ResultSupreme<List<FusedApp>> { return if (source == "Open Source" || source == "PWA") { getAppsListBasedOnCategory( category, browseUrl, authData, source ) } else { getNextDataSet(authData, browseUrl).apply { addPlaceHolderAppIfNeeded(this) } } } /** * @return a Pair, * 1. first item is the data * 1. second item is item count is changed or not */ suspend fun loadMore(authData: AuthData, browseUrl: String): Pair<ResultSupreme<List<FusedApp>>, Boolean> { val lastCount: Int = streamCluster.clusterAppList.size val result = getNextDataSet(authData, browseUrl) val newCount = streamCluster.clusterAppList.size return Pair(result, lastCount != newCount) } /** * This is how the logic works: * Loading Loading @@ -267,44 +339,11 @@ class FusedAPIRepository @Inject constructor( * Issue: https://gitlab.e.foundation/e/backlog/-/issues/5131 [2] */ private var streamBundle = StreamBundle() private var streamCluster = StreamCluster() private var clusterPointer = 0 /** * 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 /** * 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 suspend fun getNextDataSet( private suspend fun getNextDataSet( authData: AuthData, browseUrl: String, ): ResultSupreme<List<FusedApp>> { Timber.d("hasNextStreamCluster: $hasNextStreamCluster hasNextStreamBundle: $hasNextStreamBundle clusterPointer: $clusterPointer: streambundleSize: ${streamBundle.streamClusters.size} streamClusterSize: ${streamCluster.clusterAppList.size}") if (hasNextStreamCluster) { getNextStreamCluster(authData).run { if (!isSuccess()) { Loading @@ -330,9 +369,8 @@ class FusedAPIRepository @Inject constructor( } } } return filterRestrictedGPlayApps(authData, streamCluster.clusterAppList).apply { addPlaceHolderAppIfNeeded(this) } Timber.d("===> calling last segment") return filterRestrictedGPlayApps(authData, streamCluster.clusterAppList) } /** Loading @@ -349,7 +387,7 @@ class FusedAPIRepository @Inject constructor( * * @return true if a placeholder app was added, false otherwise. */ private fun addPlaceHolderAppIfNeeded(result: ResultSupreme<List<FusedApp>>): Boolean { fun addPlaceHolderAppIfNeeded(result: ResultSupreme<List<FusedApp>>): Boolean { result.apply { if (isSuccess() && canLoadMore()) { // Add an empty app at the end if more data can be loaded on scroll Loading Loading @@ -441,4 +479,12 @@ class FusedAPIRepository @Inject constructor( */ fun canLoadMore(): Boolean = hasNextStreamCluster || clusterPointer < streamBundle.streamClusters.size || hasNextStreamBundle fun clearData() { streamCluster = StreamCluster() streamBundle = StreamBundle() hasNextStreamBundle = true hasNextStreamCluster = false clusterPointer = 0 } }
app/src/main/java/foundation/e/apps/applicationlist/ApplicationListViewModel.kt +37 −267 Original line number Diff line number Diff line Loading @@ -31,6 +31,7 @@ import foundation.e.apps.api.fused.data.FusedApp import foundation.e.apps.utils.enums.Origin import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import timber.log.Timber import javax.inject.Inject @HiltViewModel Loading @@ -40,60 +41,20 @@ class ApplicationListViewModel @Inject constructor( val appListLiveData: MutableLiveData<ResultSupreme<List<FusedApp>>> = MutableLiveData() private var streamBundle = StreamBundle() private var streamCluster = StreamCluster() private var clusterPointer = 0 var isLoading = false /** * 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 /** * 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 fun getList(category: String, browseUrl: String, authData: AuthData, source: String) { Timber.d("===> getlist: $isLoading") if (isLoading) { return } viewModelScope.launch(Dispatchers.IO) { val appsListData = if (source == "Open Source" || source == "PWA") { fusedAPIRepository.getAppsListBasedOnCategory( category, browseUrl, authData, source ) } else { fusedAPIRepository.getNextDataSet(authData, browseUrl).apply { addPlaceHolderAppIfNeeded(this) } isLoading = true fusedAPIRepository.getAppList(category, browseUrl, authData, source).apply { isLoading = false appListLiveData.postValue(this) Timber.d("final result: ${this.data?.size}") } appListLiveData.postValue(appsListData) } } Loading Loading @@ -123,7 +84,7 @@ class ApplicationListViewModel @Inject constructor( */ private fun addPlaceHolderAppIfNeeded(result: ResultSupreme<List<FusedApp>>): Boolean { result.apply { if (isSuccess() && canLoadMore()) { if (isSuccess() && fusedAPIRepository.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)) Loading @@ -136,11 +97,14 @@ class ApplicationListViewModel @Inject constructor( fun loadMore(authData: AuthData, browseUrl: String) { viewModelScope.launch { if (!isLoading) { val lastCount: Int = streamCluster.clusterAppList.size val result = fusedAPIRepository.getNextDataSet(authData, browseUrl) val newCount = streamCluster.clusterAppList.size appListLiveData.postValue(result) if (isLoading) { return@launch } isLoading = true val result = fusedAPIRepository.loadMore(authData, browseUrl) isLoading = false appListLiveData.postValue(result.first!!) /* * Check if a placeholder app is to be added at the end. * If yes then post the updated result. Loading @@ -148,8 +112,8 @@ class ApplicationListViewModel @Inject constructor( * and ensures only a single placeholder app is present at the end of the * list, and none at the middle of the list. */ if (addPlaceHolderAppIfNeeded(result)) { appListLiveData.postValue(result) if (fusedAPIRepository.addPlaceHolderAppIfNeeded(result.first)) { appListLiveData.postValue(result.first!!) } /* Loading @@ -159,210 +123,11 @@ class ApplicationListViewModel @Inject constructor( * because recyclerview scroll listener will not trigger itself twice * for the same data. */ if (result.isSuccess() && lastCount == newCount && canLoadMore()) { if (result.first.isSuccess() && !result.second && fusedAPIRepository.canLoadMore()) { loadMore(authData, browseUrl) } } } } /** * 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<StreamBundle> { 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<StreamCluster> { 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<StreamCluster> { 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<List<FusedApp>> { 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 /** * @return returns true if there is changes in data, otherwise false Loading @@ -374,4 +139,9 @@ class ApplicationListViewModel @Inject constructor( fun hasAnyAppInstallStatusChanged(currentList: List<FusedApp>) = fusedAPIRepository.isAnyAppInstallStatusChanged(currentList) override fun onCleared() { fusedAPIRepository.clearData() super.onCleared() } }