From c838b7c454befec354b5ed3e5f8da1089dba68fc Mon Sep 17 00:00:00 2001 From: SayantanRC Date: Fri, 20 May 2022 17:36:15 +0530 Subject: [PATCH 01/12] App lounge: (issue_5171) update GPlayAPIImpl.kt getSearchResults() --- .../e/apps/api/gplay/GPlayAPIImpl.kt | 33 ++++++++++--------- 1 file changed, 18 insertions(+), 15 deletions(-) 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 f367cdaba..c244ca574 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 @@ -85,24 +85,27 @@ class GPlayAPIImpl @Inject constructor( suspend fun getSearchResults(query: String, authData: AuthData): List { val searchData = mutableListOf() withContext(Dispatchers.IO) { + /* + * Variable names and logic made same as that of Aurora store. + * Issue: https://gitlab.e.foundation/e/backlog/-/issues/5171 + */ val searchHelper = SearchHelper(authData).using(gPlayHttpClient) - val searchResult = searchHelper.searchResults(query) - searchData.addAll(searchResult.appList) + val searchBundle = searchHelper.searchResults(query) - // Fetch more results in case the given result is a promoted app - if (searchData.size == 1) { - val bundleSet: MutableSet = searchResult.subBundles - do { - val searchBundle = searchHelper.next(bundleSet) - if (searchBundle.appList.isNotEmpty()) { - searchData.addAll(searchBundle.appList) - } - bundleSet.apply { - clear() - addAll(searchBundle.subBundles) + var nextSubBundleSet: MutableSet + do { + nextSubBundleSet = searchBundle.subBundles + val newSearchBundle = searchHelper.next(nextSubBundleSet) + if (newSearchBundle.appList.isNotEmpty()) { + searchBundle.apply { + subBundles.clear() + subBundles.addAll(newSearchBundle.subBundles) + appList.addAll(newSearchBundle.appList) } - } while (bundleSet.isNotEmpty()) - } + } + } while (nextSubBundleSet.isNotEmpty()) + + searchData.addAll(searchBundle.appList) } return searchData } -- GitLab From a1be203ccb5b42b32e55985c02dd7f762f85e005 Mon Sep 17 00:00:00 2001 From: SayantanRC Date: Wed, 25 May 2022 18:22:49 +0530 Subject: [PATCH 02/12] issue_5171 [WIP]: Pass GPlay search results as LiveData from GPlayAPIImpl. Signatures of all methods need to be changed. --- .../java/foundation/e/apps/api/gplay/GPlayAPIImpl.kt | 11 +++++++---- .../foundation/e/apps/api/gplay/GPlayAPIRepository.kt | 3 ++- 2 files changed, 9 insertions(+), 5 deletions(-) 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 c244ca574..fc08e89d9 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 @@ -19,6 +19,8 @@ package foundation.e.apps.api.gplay import android.content.Context +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData import com.aurora.gplayapi.SearchSuggestEntry import com.aurora.gplayapi.data.models.App import com.aurora.gplayapi.data.models.AuthData @@ -82,8 +84,8 @@ class GPlayAPIImpl @Inject constructor( return searchData.filter { it.suggestedQuery.isNotBlank() } } - suspend fun getSearchResults(query: String, authData: AuthData): List { - val searchData = mutableListOf() + suspend fun getSearchResults(query: String, authData: AuthData): LiveData> { + val searchData: MutableLiveData> = MutableLiveData() withContext(Dispatchers.IO) { /* * Variable names and logic made same as that of Aurora store. @@ -92,6 +94,8 @@ class GPlayAPIImpl @Inject constructor( val searchHelper = SearchHelper(authData).using(gPlayHttpClient) val searchBundle = searchHelper.searchResults(query) + searchData.postValue(searchBundle.appList) + var nextSubBundleSet: MutableSet do { nextSubBundleSet = searchBundle.subBundles @@ -101,11 +105,10 @@ class GPlayAPIImpl @Inject constructor( subBundles.clear() subBundles.addAll(newSearchBundle.subBundles) appList.addAll(newSearchBundle.appList) + searchData.postValue(searchBundle.appList) } } } while (nextSubBundleSet.isNotEmpty()) - - searchData.addAll(searchBundle.appList) } return searchData } 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 56243a6a5..9d7a2d40d 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 @@ -18,6 +18,7 @@ package foundation.e.apps.api.gplay +import androidx.lifecycle.LiveData import com.aurora.gplayapi.SearchSuggestEntry import com.aurora.gplayapi.data.models.App import com.aurora.gplayapi.data.models.AuthData @@ -46,7 +47,7 @@ class GPlayAPIRepository @Inject constructor( return gPlayAPIImpl.getSearchSuggestions(query, authData) } - suspend fun getSearchResults(query: String, authData: AuthData): List { + suspend fun getSearchResults(query: String, authData: AuthData): LiveData> { return gPlayAPIImpl.getSearchResults(query, authData) } -- GitLab From e6be0a2ae505ab68ee2134b6db0ea4c5f719f7eb Mon Sep 17 00:00:00 2001 From: SayantanRC Date: Thu, 26 May 2022 00:04:01 +0530 Subject: [PATCH 03/12] issue_5171 [WIP]: return liveData{} from GPlayAPIImpl.getSearchResults() instead of previous MutableLiveData. Make function non-suspend. --- .../e/apps/api/gplay/GPlayAPIImpl.kt | 48 +++++++++---------- .../e/apps/api/gplay/GPlayAPIRepository.kt | 2 +- 2 files changed, 25 insertions(+), 25 deletions(-) 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 fc08e89d9..471d8f9ea 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 @@ -20,7 +20,7 @@ package foundation.e.apps.api.gplay import android.content.Context import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.liveData import com.aurora.gplayapi.SearchSuggestEntry import com.aurora.gplayapi.data.models.App import com.aurora.gplayapi.data.models.AuthData @@ -84,33 +84,33 @@ class GPlayAPIImpl @Inject constructor( return searchData.filter { it.suggestedQuery.isNotBlank() } } - suspend fun getSearchResults(query: String, authData: AuthData): LiveData> { - val searchData: MutableLiveData> = MutableLiveData() - withContext(Dispatchers.IO) { - /* - * Variable names and logic made same as that of Aurora store. - * Issue: https://gitlab.e.foundation/e/backlog/-/issues/5171 - */ - val searchHelper = SearchHelper(authData).using(gPlayHttpClient) - val searchBundle = searchHelper.searchResults(query) + fun getSearchResults(query: String, authData: AuthData): LiveData> { + return liveData { + withContext(Dispatchers.IO) { + /* + * Variable names and logic made same as that of Aurora store. + * Issue: https://gitlab.e.foundation/e/backlog/-/issues/5171 + */ + val searchHelper = SearchHelper(authData).using(gPlayHttpClient) + val searchBundle = searchHelper.searchResults(query) - searchData.postValue(searchBundle.appList) + emit(searchBundle.appList) - var nextSubBundleSet: MutableSet - do { - nextSubBundleSet = searchBundle.subBundles - val newSearchBundle = searchHelper.next(nextSubBundleSet) - if (newSearchBundle.appList.isNotEmpty()) { - searchBundle.apply { - subBundles.clear() - subBundles.addAll(newSearchBundle.subBundles) - appList.addAll(newSearchBundle.appList) - searchData.postValue(searchBundle.appList) + var nextSubBundleSet: MutableSet + do { + nextSubBundleSet = searchBundle.subBundles + val newSearchBundle = searchHelper.next(nextSubBundleSet) + if (newSearchBundle.appList.isNotEmpty()) { + searchBundle.apply { + subBundles.clear() + subBundles.addAll(newSearchBundle.subBundles) + appList.addAll(newSearchBundle.appList) + emit(searchBundle.appList) + } } - } - } while (nextSubBundleSet.isNotEmpty()) + } while (nextSubBundleSet.isNotEmpty()) + } } - return searchData } suspend fun getDownloadInfo( 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 9d7a2d40d..f268a4f9b 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 @@ -47,7 +47,7 @@ class GPlayAPIRepository @Inject constructor( return gPlayAPIImpl.getSearchSuggestions(query, authData) } - suspend fun getSearchResults(query: String, authData: AuthData): LiveData> { + fun getSearchResults(query: String, authData: AuthData): LiveData> { return gPlayAPIImpl.getSearchResults(query, authData) } -- GitLab From b960b483a7df1d8182b89df5cda895b301c0e1a1 Mon Sep 17 00:00:00 2001 From: SayantanRC Date: Thu, 26 May 2022 00:27:51 +0530 Subject: [PATCH 04/12] issue_5171 [WIP]: return livedata{} from FusedAPIImpl.getSearchResults() instead of just List, update the function signature in FusedAPIRepository --- .../e/apps/api/fused/FusedAPIImpl.kt | 73 +++++++++++++------ .../e/apps/api/fused/FusedAPIRepository.kt | 3 +- 2 files changed, 52 insertions(+), 24 deletions(-) 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 8130bf9ae..56373ec4d 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 @@ -21,6 +21,9 @@ package foundation.e.apps.api.fused import android.content.Context import android.text.format.Formatter import android.util.Log +import androidx.lifecycle.LiveData +import androidx.lifecycle.liveData +import androidx.lifecycle.map import com.aurora.gplayapi.Constants import com.aurora.gplayapi.SearchSuggestEntry import com.aurora.gplayapi.data.models.App @@ -176,30 +179,37 @@ class FusedAPIImpl @Inject constructor( * Fetches search results from cleanAPK and GPlay servers and returns them * @param query Query * @param authData [AuthData] - * @return A list of nullable [FusedApp] + * @return A livedata list of non-nullable [FusedApp]. + * Observe this livedata to display new apps as they are fetched from the network. */ - suspend fun getSearchResults(query: String, authData: AuthData): List { - val fusedResponse = mutableListOf() - - when (preferenceManagerModule.preferredApplicationType()) { - APP_TYPE_ANY -> { - fusedResponse.addAll(getCleanAPKSearchResults(query)) - fusedResponse.addAll(getGplaySearchResults(query, authData)) - } - APP_TYPE_OPEN -> { - fusedResponse.addAll(getCleanAPKSearchResults(query)) - } - APP_TYPE_PWA -> { - fusedResponse.addAll( - getCleanAPKSearchResults( - query, - CleanAPKInterface.APP_SOURCE_ANY, - CleanAPKInterface.APP_TYPE_PWA + fun getSearchResults(query: String, authData: AuthData): LiveData> { + /* + * Returning livedata to improve performance, so that we do not have to wait forever + * for all results to be fetched from network before showing them. + * Issue: https://gitlab.e.foundation/e/backlog/-/issues/5171 + */ + return liveData { + when (preferenceManagerModule.preferredApplicationType()) { + APP_TYPE_ANY -> { + val cleanApkResults = getCleanAPKSearchResults(query) + emit(cleanApkResults) + emitSource(getGplayAndCleanapkCombinedResults(query, authData, cleanApkResults)) + } + APP_TYPE_OPEN -> { + emit(getCleanAPKSearchResults(query)) + } + APP_TYPE_PWA -> { + emit( + getCleanAPKSearchResults( + query, + CleanAPKInterface.APP_SOURCE_ANY, + CleanAPKInterface.APP_TYPE_PWA + ) ) - ) + } } } - return fusedResponse.distinctBy { it.package_name } + } suspend fun getSearchSuggestions(query: String, authData: AuthData): List { @@ -589,10 +599,27 @@ class FusedAPIImpl @Inject constructor( return list } - private suspend fun getGplaySearchResults(query: String, authData: AuthData): List { + /* + * Function to return a livedata with value from cleanapk and Google Play store combined. + * Issue: https://gitlab.e.foundation/e/backlog/-/issues/5171 + */ + private fun getGplayAndCleanapkCombinedResults( + query: String, + authData: AuthData, + cleanApkResults: List + ): LiveData> { + val localList = ArrayList(cleanApkResults) + return getGplaySearchResults(query, authData).map { list -> + localList.apply { + addAll(list) + }.distinctBy { it.package_name } + } + } + + private fun getGplaySearchResults(query: String, authData: AuthData): LiveData> { val searchResults = gPlayAPIRepository.getSearchResults(query, authData) - return searchResults.map { app -> - app.transformToFusedApp() + return searchResults.map { + it.map { app -> app.transformToFusedApp() } } } 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 745dd0773..1eada6324 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 @@ -18,6 +18,7 @@ package foundation.e.apps.api.fused +import androidx.lifecycle.LiveData import com.aurora.gplayapi.SearchSuggestEntry import com.aurora.gplayapi.data.models.AuthData import com.aurora.gplayapi.data.models.Category @@ -91,7 +92,7 @@ class FusedAPIRepository @Inject constructor( return fusedAPIImpl.fetchAuthData(email, aasToken) } - suspend fun getSearchResults(query: String, authData: AuthData): List { + fun getSearchResults(query: String, authData: AuthData): LiveData> { return fusedAPIImpl.getSearchResults(query, authData) } -- GitLab From dfcec8ad6fe8361575a0642d9cb2803d37ca057b Mon Sep 17 00:00:00 2001 From: SayantanRC Date: Thu, 26 May 2022 00:31:33 +0530 Subject: [PATCH 05/12] issue_5171: update SearchViewModel and SearchFragment to properly observe the data Fused API --- .../foundation/e/apps/search/SearchFragment.kt | 2 +- .../foundation/e/apps/search/SearchViewModel.kt | 15 ++++++++++++--- 2 files changed, 13 insertions(+), 4 deletions(-) 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 7cf486ab4..348b0c5c2 100644 --- a/app/src/main/java/foundation/e/apps/search/SearchFragment.kt +++ b/app/src/main/java/foundation/e/apps/search/SearchFragment.kt @@ -218,7 +218,7 @@ class SearchFragment : shimmerLayout?.visibility = View.VISIBLE recyclerView?.visibility = View.GONE noAppsFoundLayout?.visibility = View.GONE - mainActivityViewModel.authData.value?.let { searchViewModel.getSearchResults(text, it) } + mainActivityViewModel.authData.value?.let { searchViewModel.getSearchResults(text, it, this) } } return false } diff --git a/app/src/main/java/foundation/e/apps/search/SearchViewModel.kt b/app/src/main/java/foundation/e/apps/search/SearchViewModel.kt index 8749977ad..054528562 100644 --- a/app/src/main/java/foundation/e/apps/search/SearchViewModel.kt +++ b/app/src/main/java/foundation/e/apps/search/SearchViewModel.kt @@ -18,6 +18,7 @@ package foundation.e.apps.search +import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -44,9 +45,17 @@ class SearchViewModel @Inject constructor( } } - fun getSearchResults(query: String, authData: AuthData) { - viewModelScope.launch(Dispatchers.IO) { - searchResult.postValue(fusedAPIRepository.getSearchResults(query, authData)) + /* + * Observe data from Fused API and publish the result in searchResult. + * This allows us to show apps as they are being fetched from the network, + * without having to wait for all of the apps. + * Issue: https://gitlab.e.foundation/e/backlog/-/issues/5171 + */ + fun getSearchResults(query: String, authData: AuthData, lifecycleOwner: LifecycleOwner) { + viewModelScope.launch(Dispatchers.Main) { + fusedAPIRepository.getSearchResults(query, authData).observe(lifecycleOwner) { + searchResult.postValue(it) + } } } } -- GitLab From b30a6b6f79d5b108ff4e3fce0aaba109a089a4fc Mon Sep 17 00:00:00 2001 From: SayantanRC Date: Thu, 26 May 2022 01:01:26 +0530 Subject: [PATCH 06/12] issue_5171: emit cleanapk results before gplay results only cleanapk results are not empty --- .../main/java/foundation/e/apps/api/fused/FusedAPIImpl.kt | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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 56373ec4d..aaeaab818 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 @@ -192,7 +192,13 @@ class FusedAPIImpl @Inject constructor( when (preferenceManagerModule.preferredApplicationType()) { APP_TYPE_ANY -> { val cleanApkResults = getCleanAPKSearchResults(query) - emit(cleanApkResults) + if (cleanApkResults.isNotEmpty()) { + /* + * If cleanapk results are empty, dont emit emit data as it may + * briefly show "No apps found..." + */ + emit(cleanApkResults) + } emitSource(getGplayAndCleanapkCombinedResults(query, authData, cleanApkResults)) } APP_TYPE_OPEN -> { -- GitLab From c0c8a56c8cb7a1cbb8f4bca7ff55764153afb942 Mon Sep 17 00:00:00 2001 From: SayantanRC Date: Thu, 26 May 2022 01:04:50 +0530 Subject: [PATCH 07/12] issue_5171: fix scrolling to top for each new livedata results for the same query --- .../foundation/e/apps/search/SearchFragment.kt | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) 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 348b0c5c2..63bea5d6a 100644 --- a/app/src/main/java/foundation/e/apps/search/SearchFragment.kt +++ b/app/src/main/java/foundation/e/apps/search/SearchFragment.kt @@ -79,6 +79,7 @@ class SearchFragment : private val appProgressViewModel: AppProgressViewModel by viewModels() private val SUGGESTION_KEY = "suggestion" + private var lastSearch = "" private var searchView: SearchView? = null private var shimmerLayout: ShimmerFrameLayout? = null @@ -193,7 +194,19 @@ class SearchFragment : } listAdapter?.registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() { override fun onItemRangeInserted(positionStart: Int, itemCount: Int) { - recyclerView!!.scrollToPosition(0) + searchView?.run { + /* + * Only scroll back to 0 position for a new search. + * + * If we are getting new results from livedata for the old search query, + * do not scroll to top as the user may be scrolling to see already + * populated results. + */ + if (lastSearch != query?.toString()) { + recyclerView?.scrollToPosition(0) + lastSearch = query.toString() + } + } } }) } -- GitLab From f707a623386a2758f76c38e1dd5d3dd92b5d61b6 Mon Sep 17 00:00:00 2001 From: SayantanRC Date: Thu, 26 May 2022 01:10:35 +0530 Subject: [PATCH 08/12] issue_5171: minor documentation --- app/src/main/java/foundation/e/apps/api/gplay/GPlayAPIImpl.kt | 4 ++++ 1 file changed, 4 insertions(+) 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 471d8f9ea..af6ef1c5c 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 @@ -85,6 +85,10 @@ class GPlayAPIImpl @Inject constructor( } fun getSearchResults(query: String, authData: AuthData): LiveData> { + /* + * Send livedata to improve UI performance, so we don't have to wait for loading all results. + * Issue: https://gitlab.e.foundation/e/backlog/-/issues/5171 + */ return liveData { withContext(Dispatchers.IO) { /* -- GitLab From 1bc25e6e549dc279b1b1ed72ae59bb4f82a686c7 Mon Sep 17 00:00:00 2001 From: SayantanRC Date: Fri, 10 Jun 2022 18:45:31 +0530 Subject: [PATCH 09/12] Merge main in 5171-fix_limited_search_results. Fix merge conflicts. --- README.md | 121 ++-- app/build.gradle | 7 +- .../foundation/e/apps/AppProgressViewModel.kt | 20 +- .../java/foundation/e/apps/MainActivity.kt | 10 + .../e/apps/MainActivityViewModel.kt | 130 +--- .../java/foundation/e/apps/api/JobResult.kt | 78 +++ .../foundation/e/apps/api/ResultSupreme.kt | 170 +++++ .../api/cleanapk/ApplicationDeserializer.kt | 41 ++ .../e/apps/api/cleanapk/CleanAPKRepository.kt | 5 +- .../apps/api/cleanapk/CleanApkAppDetailApi.kt | 41 ++ .../e/apps/api/cleanapk/RetrofitModule.kt | 32 + .../e/apps/api/ecloud/EcloudApiInterface.kt | 3 +- .../e/apps/api/ecloud/EcloudRepository.kt | 2 +- .../e/apps/api/ecloud/modules/FaultyToken.kt | 2 +- .../e/apps/api/fused/FusedAPIImpl.kt | 583 +++++++++++++----- .../e/apps/api/fused/FusedAPIRepository.kt | 57 +- .../e/apps/api/fused/data/FusedApp.kt | 16 + .../e/apps/api/gplay/GPlayAPIImpl.kt | 162 +++-- .../e/apps/api/gplay/GPlayAPIRepository.kt | 31 +- .../e/apps/application/ApplicationFragment.kt | 106 +++- .../apps/application/ApplicationViewModel.kt | 9 +- .../ApplicationListFragment.kt | 153 +++-- .../ApplicationListViewModel.kt | 394 +++++++++--- .../model/ApplicationListRVAdapter.kt | 27 +- .../e/apps/categories/AppsFragment.kt | 94 ++- .../e/apps/categories/CategoriesFragment.kt | 27 +- .../e/apps/categories/CategoriesViewModel.kt | 8 +- .../e/apps/categories/GamesFragment.kt | 73 ++- .../foundation/e/apps/home/HomeFragment.kt | 135 +++- .../foundation/e/apps/home/HomeViewModel.kt | 13 +- .../e/apps/manager/database/DatabaseModule.kt | 10 +- .../e/apps/manager/database/FusedDatabase.kt | 21 +- .../manager/download/DownloadManagerUtils.kt | 8 +- .../manager/download/data/DownloadProgress.kt | 3 +- .../download/data/DownloadProgressLD.kt | 11 +- .../e/apps/manager/fused/FusedManagerImpl.kt | 20 + .../e/apps/manager/pkg/InstallerService.kt | 19 +- .../manager/workmanager/InstallAppWorker.kt | 154 +++-- .../manager/workmanager/InstallWorkManager.kt | 2 +- .../e/apps/search/SearchFragment.kt | 146 ++++- .../e/apps/search/SearchViewModel.kt | 4 +- .../e/apps/updates/UpdatesFragment.kt | 129 +++- .../e/apps/updates/UpdatesViewModel.kt | 46 +- .../updates/manager/UpdatesManagerImpl.kt | 28 +- .../manager/UpdatesManagerRepository.kt | 7 +- .../updates/manager/UpdatesWorkManager.kt | 2 +- .../e/apps/updates/manager/UpdatesWorker.kt | 20 +- .../e/apps/utils/enums/ResultStatus.kt | 7 + .../foundation/e/apps/utils/enums/Status.kt | 3 +- .../utils/parentFragment/TimeoutFragment.kt | 214 +++++++ .../main/res/layout/application_list_item.xml | 26 +- .../res/navigation/navigation_resource.xml | 3 + app/src/main/res/values-de/strings.xml | 12 +- 53 files changed, 2641 insertions(+), 804 deletions(-) create mode 100644 app/src/main/java/foundation/e/apps/api/JobResult.kt create mode 100644 app/src/main/java/foundation/e/apps/api/ResultSupreme.kt create mode 100644 app/src/main/java/foundation/e/apps/api/cleanapk/ApplicationDeserializer.kt create mode 100644 app/src/main/java/foundation/e/apps/api/cleanapk/CleanApkAppDetailApi.kt create mode 100644 app/src/main/java/foundation/e/apps/utils/enums/ResultStatus.kt create mode 100644 app/src/main/java/foundation/e/apps/utils/parentFragment/TimeoutFragment.kt diff --git a/README.md b/README.md index b90276d61..ac48fc234 100644 --- a/README.md +++ b/README.md @@ -1,39 +1,82 @@ -# App Lounge - -App Lounge is an open-source application that allows you to install Android apps on your device quickly and easily. It is licensed and distributed under [The GNU General Public License v3.0](https://www.gnu.org/licenses/gpl-3.0.en.html). - -## Project Structure - -App Lounge use the _Packaging by Features_ approach for packaging the code. A really good explanation for this approach can be found on Philip Hauer's [Package by Feature](https://web.archive.org/web/20211025104408/https://phauer.com/2020/package-by-feature/) blog post. - -``` -. -├── api -│ ├── cleanapk -│ ├── fused -│ └── gplay -├── application -├── applicationlist -├── categories -├── home -├── manager -├── search -├── settings -├── setup -├── updates -└── utils -``` - -## API - -App Lounge use the following APIs to offer applications: - -- [GPlayApi](https://gitlab.com/AuroraOSS/gplayapi) from Aurora OSS -- [CleanAPK API](https://info.cleanapk.org/) from CleanAPK - -## Development - -- Documentation regarding development can be found on this repository's [wiki](https://gitlab.e.foundation/e/apps/apps/-/wikis/home) -- A the list of contributors can be viewed on this repository's [contributors graph](https://gitlab.e.foundation/e/apps/apps/-/graphs/master). - -In case you wish to contribute to the development of this project, feel free to open a [Merge Request](https://gitlab.e.foundation/e/apps/apps/-/merge_requests) or an [Issue](https://gitlab.e.foundation/e/backlog/-/issues/) for the same. Contributions are always welcome. +# App Lounge + +App Lounge is an open-source application that allows you to install Android apps on your device quickly and easily. It is licensed and distributed under [The GNU General Public License v3.0](https://www.gnu.org/licenses/gpl-3.0.en.html). + +## Project Structure + +App Lounge use the _Packaging by Features_ approach for packaging the code. A really good explanation for this approach can be found on Philip Hauer's [Package by Feature](https://web.archive.org/web/20211025104408/https://phauer.com/2020/package-by-feature/) blog post. + +``` +. +├── api +│   ├── cleanapk +│   │   ├── blockedApps +│   │   └── data +│   │   ├── app +│   │   ├── categories +│   │   ├── download +│   │   ├── home +│   │   └── search +│   ├── database +│   ├── ecloud +│   │   └── modules +│   ├── exodus +│   │   ├── models +│   │   └── repositories +│   ├── fdroid +│   │   └── models +│   ├── fused +│   │   ├── data +│   │   └── utils +│   └── gplay +│   ├── token +│   └── utils +├── application +│   ├── model +│   └── subFrags +├── applicationlist +│   └── model +├── categories +│   └── model +├── di +├── home +│   └── model +├── manager +│   ├── database +│   │   └── fusedDownload +│   ├── download +│   │   └── data +│   ├── fused +│   ├── notification +│   ├── pkg +│   └── workmanager +├── purchase +├── receiver +├── search +├── settings +├── setup +│   ├── signin +│   │   └── google +│   └── tos +├── updates +│   └── manager +└── utils + ├── enums + └── modules + +``` + +## API + +App Lounge use the following APIs to offer applications: + +- [GPlayApi](https://gitlab.com/AuroraOSS/gplayapi) from Aurora OSS +- [CleanAPK API](https://info.cleanapk.org/) from CleanAPK +- [Exodus API](https://github.com/Exodus-Privacy/exodus/blob/v1/doc/api.md) from Exodus-Privacy + +## Development + +- Documentation regarding development can be found on this repository's [wiki](https://gitlab.e.foundation/e/apps/apps/-/wikis/home) +- A the list of contributors can be viewed on this repository's [contributors graph](https://gitlab.e.foundation/e/apps/apps/-/graphs/master). + +In case you wish to contribute to the development of this project, feel free to open a [Merge Request](https://gitlab.e.foundation/e/apps/apps/-/merge_requests) or an [Issue](https://gitlab.e.foundation/e/backlog/-/issues/) for the same. Contributions are always welcome. diff --git a/app/build.gradle b/app/build.gradle index 85e493d13..09d7acdd5 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -8,8 +8,8 @@ plugins { } def versionMajor = 2 -def versionMinor = 1 -def versionPatch = 1 +def versionMinor = 2 +def versionPatch = 0 android { compileSdk 31 @@ -125,6 +125,9 @@ dependencies { // implementation "com.squareup.moshi:moshi-adapters:1.5.0" implementation "com.squareup.okhttp3:okhttp:4.9.2" + // JSON Converter + implementation 'com.squareup.retrofit2:converter-gson:2.5.0' + // YAML factory implementation "com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.11.2" diff --git a/app/src/main/java/foundation/e/apps/AppProgressViewModel.kt b/app/src/main/java/foundation/e/apps/AppProgressViewModel.kt index 42b72c5f0..ee1bfabcf 100644 --- a/app/src/main/java/foundation/e/apps/AppProgressViewModel.kt +++ b/app/src/main/java/foundation/e/apps/AppProgressViewModel.kt @@ -19,15 +19,19 @@ class AppProgressViewModel @Inject constructor( suspend fun calculateProgress( fusedApp: FusedApp?, progress: DownloadProgress - ): Pair { + ): Int { fusedApp?.let { app -> val appDownload = fusedManagerRepository.getDownloadList() .singleOrNull { it.id.contentEquals(app._id) && it.packageName.contentEquals(app.package_name) } - ?: return Pair(1, 0) + ?: return 0 if (!appDownload.id.contentEquals(app._id) || !appDownload.packageName.contentEquals(app.package_name)) { return@let } + + if (!isProgressValidForApp(fusedApp, progress)) { + return -1 + } val downloadingMap = progress.totalSizeBytes.filter { item -> appDownload.downloadIdMap.keys.contains(item.key) } @@ -36,8 +40,16 @@ class AppProgressViewModel @Inject constructor( appDownload.downloadIdMap.keys.contains(item.key) }.values.sum() - return Pair(totalSizeBytes, downloadedSoFar) + return ((downloadedSoFar / totalSizeBytes.toDouble()) * 100).toInt() } - return Pair(1, 0) + return 0 + } + + private suspend fun isProgressValidForApp( + fusedApp: FusedApp, + downloadProgress: DownloadProgress + ): Boolean { + val download = fusedManagerRepository.getFusedDownload(downloadProgress.downloadId) + return download.id == fusedApp._id } } diff --git a/app/src/main/java/foundation/e/apps/MainActivity.kt b/app/src/main/java/foundation/e/apps/MainActivity.kt index ab787c6ef..f095a39d5 100644 --- a/app/src/main/java/foundation/e/apps/MainActivity.kt +++ b/app/src/main/java/foundation/e/apps/MainActivity.kt @@ -46,6 +46,7 @@ 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.modules.CommonUtilsModule +import foundation.e.apps.utils.parentFragment.TimeoutFragment import kotlinx.coroutines.launch import java.io.File import java.util.UUID @@ -140,6 +141,15 @@ class MainActivity : AppCompatActivity() { generateAuthDataBasedOnUserType(user) } else { 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 + ) + lastFragment.onTimeout() + } } } } else { diff --git a/app/src/main/java/foundation/e/apps/MainActivityViewModel.kt b/app/src/main/java/foundation/e/apps/MainActivityViewModel.kt index 82d0ebd57..4e54172e0 100644 --- a/app/src/main/java/foundation/e/apps/MainActivityViewModel.kt +++ b/app/src/main/java/foundation/e/apps/MainActivityViewModel.kt @@ -18,15 +18,12 @@ package foundation.e.apps -import android.app.Activity import android.content.Context -import android.content.DialogInterface import android.graphics.Bitmap import android.os.Build import android.os.SystemClock import android.util.Base64 import android.util.Log -import android.view.KeyEvent import android.widget.ImageView import androidx.annotation.RequiresApi import androidx.appcompat.app.AlertDialog @@ -43,14 +40,11 @@ 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 import foundation.e.apps.manager.fused.FusedManagerRepository import foundation.e.apps.manager.pkg.PkgManagerModule -import foundation.e.apps.manager.workmanager.InstallWorkManager -import foundation.e.apps.settings.SettingsFragment import foundation.e.apps.utils.enums.Origin import foundation.e.apps.utils.enums.Status import foundation.e.apps.utils.enums.Type @@ -95,108 +89,6 @@ class MainActivityViewModel @Inject constructor( */ var firstAuthDataFetchTime = 0L - /* - * Alert dialog to show to user if App Lounge times out. - * - * Issue: https://gitlab.e.foundation/e/backlog/-/issues/5404 - */ - private lateinit var timeoutAlertDialog: AlertDialog - - /** - * Display timeout alert dialog. - * - * @param activity Activity class. Basically the MainActivity. - * @param positiveButtonBlock Code block when "Retry" is pressed. - * @param openSettings Code block when "Open Settings" button is pressed. - * This should open the [SettingsFragment] fragment. - * @param applicationTypeFromPreferences Application type string, can be one of - * [FusedAPIImpl.APP_TYPE_ANY], [FusedAPIImpl.APP_TYPE_OPEN], [FusedAPIImpl.APP_TYPE_PWA] - */ - fun displayTimeoutAlertDialog( - activity: Activity, - positiveButtonBlock: () -> Unit, - openSettings: () -> Unit, - applicationTypeFromPreferences: String, - ) { - if (!this::timeoutAlertDialog.isInitialized) { - timeoutAlertDialog = AlertDialog.Builder(activity).apply { - setTitle(R.string.timeout_title) - /* - * Prevent dismissing the dialog from pressing outside as it will only - * show a blank screen below the dialog. - */ - setCancelable(false) - /* - * If user presses back button to close the dialog without selecting anything, - * close App Lounge. - */ - setOnKeyListener { dialog, keyCode, _ -> - if (keyCode == KeyEvent.KEYCODE_BACK) { - dialog.dismiss() - activity.finish() - } - true - } - }.create() - } - - timeoutAlertDialog.apply { - /* - * Set retry button. - */ - setButton(DialogInterface.BUTTON_POSITIVE, activity.getString(R.string.retry)) { _, _ -> - positiveButtonBlock() - } - /* - * Set message based on apps from GPlay of cleanapk. - */ - setMessage( - activity.getString( - when (applicationTypeFromPreferences) { - FusedAPIImpl.APP_TYPE_ANY -> R.string.timeout_desc_gplay - else -> R.string.timeout_desc_cleanapk - } - ) - ) - /* - * Show "Open Setting" only for GPlay apps. - */ - if (applicationTypeFromPreferences == FusedAPIImpl.APP_TYPE_ANY) { - setButton( - DialogInterface.BUTTON_NEUTRAL, - activity.getString(R.string.open_settings) - ) { _, _ -> - openSettings() - } - } - } - - timeoutAlertDialog.show() - } - - /** - * Returns true if [timeoutAlertDialog] is displaying. - * Returs false if it is not initialised. - */ - fun isTimeoutDialogDisplayed(): Boolean { - return if (this::timeoutAlertDialog.isInitialized) { - timeoutAlertDialog.isShowing - } else false - } - - /** - * Dismisses the [timeoutAlertDialog] if it is being displayed. - * Does nothing if it is not being displayed. - * Caller need not check if the dialog is being displayed. - */ - fun dismissTimeoutDialog() { - if (isTimeoutDialogDisplayed()) { - try { - timeoutAlertDialog.dismiss() - } catch (_: Exception) {} - } - } - // Downloads val downloadList = fusedManagerRepository.getDownloadLiveList() var installInProgress = false @@ -214,7 +106,9 @@ class MainActivityViewModel @Inject constructor( } fun setFirstTokenFetchTime() { - firstAuthDataFetchTime = SystemClock.uptimeMillis() + if (firstAuthDataFetchTime == 0L) { + firstAuthDataFetchTime = SystemClock.uptimeMillis() + } } fun isTimeEligibleForTokenRefresh(): Boolean { @@ -229,11 +123,12 @@ class MainActivityViewModel @Inject constructor( * Issue: https://gitlab.e.foundation/e/backlog/-/issues/5404 */ fun retryFetchingTokenAfterTimeout() { + firstAuthDataFetchTime = 0 setFirstTokenFetchTime() authValidity.postValue(false) } - fun uploadFaultyTokenToEcloud(description: String){ + fun uploadFaultyTokenToEcloud(description: String) { viewModelScope.launch { authData.value?.let { authData -> val email: String = authData.run { @@ -249,7 +144,19 @@ class MainActivityViewModel @Inject constructor( if (!authRequestRunning) { authRequestRunning = true viewModelScope.launch { - fusedAPIRepository.fetchAuthData() + /* + * If getting auth data failed, try getting again. + * Sending false in authValidity, triggers observer in MainActivity, + * causing it to destroy credentials and try to regenerate auth data. + * + * Issue: + * https://gitlab.e.foundation/e/backlog/-/issues/5413 + * https://gitlab.e.foundation/e/backlog/-/issues/5404 + */ + if (!fusedAPIRepository.fetchAuthData()) { + authRequestRunning = false + authValidity.postValue(false) + } } } } @@ -474,7 +381,6 @@ class MainActivityViewModel @Inject constructor( val fusedDownload = fusedManagerRepository.getFusedDownload(packageName = app.package_name) fusedManagerRepository.cancelDownload(fusedDownload) - InstallWorkManager.cancelWork(app.name) } } diff --git a/app/src/main/java/foundation/e/apps/api/JobResult.kt b/app/src/main/java/foundation/e/apps/api/JobResult.kt new file mode 100644 index 000000000..b632a1366 --- /dev/null +++ b/app/src/main/java/foundation/e/apps/api/JobResult.kt @@ -0,0 +1,78 @@ +package foundation.e.apps.api + +import foundation.e.apps.utils.enums.ResultStatus + +/** + * Currently defunct, not being used anywhere. + * Prototype to merge API request and also get rid of Pair, Triple for timeout related cases. + */ +open class JobResult private constructor(val status: ResultStatus) { + + /* + * Classes for returning multiple data from a function along with a status + * in the form of ResultStatus. + * Use the static overloaded create methods (in companion object) to for easy creation. + * + * 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) + + var message = "" + + /* + * This is the primary data, mainly for API requests which might send null data. + * Other data (type B, C ...) are secondary/optional data. + * + * For non-null return type, directly use of1, of2, of3 ... classes + * and directly access data1, data2, data3 ... + */ + val data: T? get() = when (this) { + is of1 -> this.data1 + is of2 -> this.data1 + is of3 -> this.data1 + else -> null + } + + fun isSuccess(): Boolean { + return status == ResultStatus.OK + } + + companion object { + fun create(data1: A, status: ResultStatus, message: String? = null): of1 { + return of1(data1, status).apply { + message?.let { this.message = message } + } + } + 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 { + return of3(data1, data2, data3, status).apply { + message?.let { this.message = message } + } + } + + /* + * Methods for API + */ + fun success(data: T): JobResult { + return of1(data, ResultStatus.OK) + } + fun error(message: String, data: T? = null): JobResult { + val result = if (data == null) JobResult(ResultStatus.UNKNOWN) + else of1(data, ResultStatus.UNKNOWN) + return result.apply { + this.message = message + } + } + /*fun loading(data: T?): JobResult { + return if (data == null) JobResult(ResultStatus.LOADING) + else JobResult.of1(data, ResultStatus.LOADING) + }*/ + } +} diff --git a/app/src/main/java/foundation/e/apps/api/ResultSupreme.kt b/app/src/main/java/foundation/e/apps/api/ResultSupreme.kt new file mode 100644 index 000000000..152803513 --- /dev/null +++ b/app/src/main/java/foundation/e/apps/api/ResultSupreme.kt @@ -0,0 +1,170 @@ +/* + * Copyright (C) 2022 ECORP + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package foundation.e.apps.api + +import foundation.e.apps.utils.enums.ResultStatus +import java.util.concurrent.TimeoutException + +/** + * Another implementation of Result class. + * This removes the use of [ResultStatus] class for different status. + * This class also follows the standard code patterns. However, we still have the same + * flaw that [data] is nullable. As such we may have to add extra null checks or just + * brute force with !! + * + * Also since for each case we now use an inner class with slightly different name, + * we need some refactoring. + * + * Issue: https://gitlab.e.foundation/e/os/backlog/-/issues/313 + */ +sealed class ResultSupreme { + + /** + * Success case. + * Use [isSuccess] to check. + * + * @param data End result of processing. + */ + class Success(data: T) : ResultSupreme() { + init { setData(data) } + } + + /** + * Timed out during network related job. + * Use [isTimeout] to check. + * + * @param data The process is expected to output some blank data, but it cannot be null. + * Example can be an empty list. + * @param exception Optional exception from try-catch block. + */ + class Timeout(data: T, exception: Exception = TimeoutException()) : + ResultSupreme() { + init { + setData(data) + this.exception = exception + } + } + + /** + * Miscellaneous error case. + * No valid data from processing. + * Use [isUnknownError] to check. + */ + class Error() : ResultSupreme() { + /** + * @param message A String message to log or display to the user. + * @param exception Optional exception from try-catch block. + */ + constructor(message: String, exception: Exception = Exception()) : this() { + this.message = message + this.exception = exception + } + + /** + * @param data Non-null data. Example a String which could not be parsed into a JSON. + * @param message A optional String message to log or display to the user. + */ + constructor(data: T, message: String = "") : this() { + setData(data) + this.message = message + } + } + + /** + * Data from processing. May be null. + */ + var data: T? = null + private set + + /** + * A custom string message for logging or displaying to the user. + */ + var message: String = "" + + /** + * Exception from try-catch block for error cases. + */ + var exception: Exception = Exception() + + fun isValidData() = data != null + + fun isSuccess() = this is Success && isValidData() + fun isTimeout() = this is Timeout + fun isUnknownError() = this is Error + + fun setData(data: T) { + this.data = data + } + + companion object { + + /** + * Function to create an instance of ResultSupreme from a [ResultStatus] status, + * and other available info - [data], [message], [exception]. + */ + fun create( + status: ResultStatus, + data: T? = null, + message: String = "", + exception: Exception = Exception(), + ): ResultSupreme { + val resultObject = when { + status == ResultStatus.OK && data != null -> Success(data) + status == ResultStatus.TIMEOUT && data != null -> Timeout(data) + else -> Error(message, exception) + } + resultObject.apply { + if (isUnknownError()) { + this.data = data + } else { + this.message = message + this.exception = exception + } + } + return resultObject + } + + /** + * Create a similar [ResultSupreme] instance i.e. of type [Success], [Timeout]... + * using a supplied [result] object but with a different generic type and new data. + * + * @param result Class of [ResultSupreme] whose replica is to be made. + * @param newData Nullable new data for this replica. + * @param message Optional new message for this replica. If not provided, + * the new object will get the message from [result]. + * @param exception Optional new exception for this replica. If not provided, + * the new object will get the exception from [result]. + */ + fun replicate( + result: ResultSupreme<*>, + newData: T?, + message: String? = null, + exception: Exception? = null, + ): ResultSupreme { + val status = when (result) { + is Success -> ResultStatus.OK + is Timeout -> ResultStatus.TIMEOUT + is Error -> ResultStatus.UNKNOWN + } + return create( + status, newData, message ?: result.message, + exception ?: result.exception + ) + } + } +} diff --git a/app/src/main/java/foundation/e/apps/api/cleanapk/ApplicationDeserializer.kt b/app/src/main/java/foundation/e/apps/api/cleanapk/ApplicationDeserializer.kt new file mode 100644 index 000000000..a62e7788c --- /dev/null +++ b/app/src/main/java/foundation/e/apps/api/cleanapk/ApplicationDeserializer.kt @@ -0,0 +1,41 @@ +/* + * Copyright ECORP SAS 2022 + * Apps Quickly and easily install Android apps onto your device! + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package foundation.e.apps.api.cleanapk + +import com.google.gson.Gson +import com.google.gson.JsonDeserializationContext +import com.google.gson.JsonDeserializer +import com.google.gson.JsonElement +import foundation.e.apps.api.cleanapk.data.app.Application + +class ApplicationDeserializer : JsonDeserializer { + override fun deserialize( + json: JsonElement?, + typeOfT: java.lang.reflect.Type?, + context: JsonDeserializationContext? + ): Application { + val gson = Gson() + val application = gson.fromJson(json?.asJsonObject?.toString(), Application::class.java) + val lastUpdate = application.app.latest_downloaded_version + val lastUpdatedOn = json?.asJsonObject?.get("app")?.asJsonObject?.get(lastUpdate) + ?.asJsonObject?.get("update_on")?.asString ?: "" + application.app.updatedOn = lastUpdatedOn + return application + } +} diff --git a/app/src/main/java/foundation/e/apps/api/cleanapk/CleanAPKRepository.kt b/app/src/main/java/foundation/e/apps/api/cleanapk/CleanAPKRepository.kt index 35a6495cd..9bc56e6fe 100644 --- a/app/src/main/java/foundation/e/apps/api/cleanapk/CleanAPKRepository.kt +++ b/app/src/main/java/foundation/e/apps/api/cleanapk/CleanAPKRepository.kt @@ -27,7 +27,8 @@ import retrofit2.Response import javax.inject.Inject class CleanAPKRepository @Inject constructor( - private val cleanAPKInterface: CleanAPKInterface + private val cleanAPKInterface: CleanAPKInterface, + private val cleanApkAppDetailApi: CleanApkAppDetailApi ) { suspend fun getHomeScreenData( @@ -42,7 +43,7 @@ class CleanAPKRepository @Inject constructor( architectures: List? = null, type: String? = null ): Response { - return cleanAPKInterface.getAppOrPWADetailsByID(id, architectures, type) + return cleanApkAppDetailApi.getAppOrPWADetailsByID(id, architectures, type) } suspend fun searchApps( diff --git a/app/src/main/java/foundation/e/apps/api/cleanapk/CleanApkAppDetailApi.kt b/app/src/main/java/foundation/e/apps/api/cleanapk/CleanApkAppDetailApi.kt new file mode 100644 index 000000000..f6795e12c --- /dev/null +++ b/app/src/main/java/foundation/e/apps/api/cleanapk/CleanApkAppDetailApi.kt @@ -0,0 +1,41 @@ +/* + * + * * Copyright ECORP SAS 2022 + * * Apps Quickly and easily install Android apps onto your device! + * * + * * This program is free software: you can redistribute it and/or modify + * * it under the terms of the GNU General Public License as published by + * * the Free Software Foundation, either version 3 of the License, or + * * (at your option) any later version. + * * + * * This program is distributed in the hope that it will be useful, + * * but WITHOUT ANY WARRANTY; without even the implied warranty of + * * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * * GNU General Public License for more details. + * * + * * You should have received a copy of the GNU General Public License + * * along with this program. If not, see . + * + */ + +package foundation.e.apps.api.cleanapk + +import foundation.e.apps.api.cleanapk.data.app.Application +import retrofit2.Response +import retrofit2.http.GET +import retrofit2.http.Query + +interface CleanApkAppDetailApi { + + companion object { + // API endpoints + const val BASE_URL = "https://api.cleanapk.org/v2/" + } + + @GET("apps?action=app_detail") + suspend fun getAppOrPWADetailsByID( + @Query("id") id: String, + @Query("architectures") architectures: List? = null, + @Query("type") type: String? = null + ): Response +} diff --git a/app/src/main/java/foundation/e/apps/api/cleanapk/RetrofitModule.kt b/app/src/main/java/foundation/e/apps/api/cleanapk/RetrofitModule.kt index 2672b48f0..e0cd5630a 100644 --- a/app/src/main/java/foundation/e/apps/api/cleanapk/RetrofitModule.kt +++ b/app/src/main/java/foundation/e/apps/api/cleanapk/RetrofitModule.kt @@ -22,12 +22,15 @@ import android.os.Build import android.util.Log import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.dataformat.yaml.YAMLFactory +import com.google.gson.Gson +import com.google.gson.GsonBuilder import com.squareup.moshi.Moshi import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent +import foundation.e.apps.api.cleanapk.data.app.Application import foundation.e.apps.api.ecloud.EcloudApiInterface import foundation.e.apps.api.exodus.ExodusTrackerApi import foundation.e.apps.api.fdroid.FdroidApiInterface @@ -39,6 +42,7 @@ import okhttp3.Protocol import okhttp3.Response import okhttp3.ResponseBody.Companion.toResponseBody import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory import retrofit2.converter.jackson.JacksonConverterFactory import retrofit2.converter.moshi.MoshiConverterFactory import java.net.ConnectException @@ -65,6 +69,24 @@ object RetrofitModule { .create(CleanAPKInterface::class.java) } + /** + * Provides an instance of Retrofit to work with CleanAPK API + * @return instance of [CleanApkAppDetailApi] + */ + @Singleton + @Provides + fun provideCleanAPKDetailApi( + okHttpClient: OkHttpClient, + @Named("gsonCustomAdapter") gson: Gson + ): CleanApkAppDetailApi { + return Retrofit.Builder() + .baseUrl(CleanAPKInterface.BASE_URL) + .client(okHttpClient) + .addConverterFactory(GsonConverterFactory.create(gson)) + .build() + .create(CleanApkAppDetailApi::class.java) + } + @Singleton @Provides fun provideExodusApi(okHttpClient: OkHttpClient, moshi: Moshi): ExodusTrackerApi { @@ -114,6 +136,16 @@ object RetrofitModule { .build() } + @Singleton + @Provides + @Named("gsonCustomAdapter") + fun getGson(): Gson { + return GsonBuilder() + .registerTypeAdapter(Application::class.java, ApplicationDeserializer()) + .enableComplexMapKeySerialization() + .create() + } + /** * Used in above [provideFdroidApi]. * Reference: https://stackoverflow.com/a/69859687 diff --git a/app/src/main/java/foundation/e/apps/api/ecloud/EcloudApiInterface.kt b/app/src/main/java/foundation/e/apps/api/ecloud/EcloudApiInterface.kt index 9b02ef35c..763298e40 100644 --- a/app/src/main/java/foundation/e/apps/api/ecloud/EcloudApiInterface.kt +++ b/app/src/main/java/foundation/e/apps/api/ecloud/EcloudApiInterface.kt @@ -33,5 +33,4 @@ interface EcloudApiInterface { @Body faultyToken: FaultyToken, @Header("Content-Type") contentType: String = "application/json", ) - -} \ No newline at end of file +} diff --git a/app/src/main/java/foundation/e/apps/api/ecloud/EcloudRepository.kt b/app/src/main/java/foundation/e/apps/api/ecloud/EcloudRepository.kt index a0dbd939e..27fb20699 100644 --- a/app/src/main/java/foundation/e/apps/api/ecloud/EcloudRepository.kt +++ b/app/src/main/java/foundation/e/apps/api/ecloud/EcloudRepository.kt @@ -34,4 +34,4 @@ class EcloudRepository @Inject constructor( e.printStackTrace() } } -} \ No newline at end of file +} diff --git a/app/src/main/java/foundation/e/apps/api/ecloud/modules/FaultyToken.kt b/app/src/main/java/foundation/e/apps/api/ecloud/modules/FaultyToken.kt index 9e1efa0ec..29f596bfd 100644 --- a/app/src/main/java/foundation/e/apps/api/ecloud/modules/FaultyToken.kt +++ b/app/src/main/java/foundation/e/apps/api/ecloud/modules/FaultyToken.kt @@ -17,4 +17,4 @@ package foundation.e.apps.api.ecloud.modules -data class FaultyToken(val email: String, val description: String) \ No newline at end of file +data class FaultyToken(val email: String, val description: String) 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 aaeaab818..770ef06cb 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 @@ -30,9 +30,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 @@ -48,6 +51,7 @@ 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.modules.CommonUtilsModule.timeoutDurationInMillis @@ -86,20 +90,16 @@ class FusedAPIImpl @Inject constructor( private var TAG = FusedAPIImpl::class.java.simpleName /** - * Pass application source type along with list of apps. - * Application source type may change in case of timeout of GPlay/cleanapk api. + * Pass list of FusedHome and status. + * Second argument can be of [ResultStatus.TIMEOUT] to indicate timeout. * - * The second item of the Pair can be one of [APP_TYPE_ANY], [APP_TYPE_OPEN], [APP_TYPE_PWA]. - * - * Issue: https://gitlab.e.foundation/e/backlog/-/issues/5404 + * Issue: + * https://gitlab.e.foundation/e/backlog/-/issues/5404 + * https://gitlab.e.foundation/e/backlog/-/issues/5413 */ - suspend fun getHomeScreenData(authData: AuthData): Pair, String> { + suspend fun getHomeScreenData(authData: AuthData): Pair, ResultStatus> { val preferredApplicationType = preferenceManagerModule.preferredApplicationType() - val initialData = getHomeScreenDataBasedOnApplicationType(authData, preferredApplicationType) - if (isFusedHomesEmpty(initialData.first)) { - Log.d(TAG, "Received empty home data.") - } - return initialData + return getHomeScreenDataBasedOnApplicationType(authData, preferredApplicationType) } /** @@ -114,6 +114,10 @@ class FusedAPIImpl @Inject constructor( return true } + fun getApplicationCategoryPreference(): String { + return preferenceManagerModule.preferredApplicationType() + } + /* * Offload fetching application to a different method to dynamically fallback to a different * app source if the user selected app source times out. @@ -123,8 +127,9 @@ class FusedAPIImpl @Inject constructor( private suspend fun getHomeScreenDataBasedOnApplicationType( authData: AuthData, applicationType: String - ): Pair, String> { + ): Pair, ResultStatus> { val list = mutableListOf() + var apiStatus = ResultStatus.OK try { /* * Each category of home apps (example "Top Free Apps") will have its own timeout. @@ -155,24 +160,48 @@ class FusedAPIImpl @Inject constructor( } } catch (e: TimeoutCancellationException) { e.printStackTrace() + apiStatus = ResultStatus.TIMEOUT Log.d(TAG, "Timed out fetching home data for type: $applicationType") } catch (e: Exception) { + apiStatus = ResultStatus.UNKNOWN e.printStackTrace() } - return Pair(list, applicationType) + return Pair(list, apiStatus) } - suspend fun getCategoriesList(type: Category.Type, authData: AuthData): List { + /* + * Return three elements from the function. + * - List : List of categories. + * - String : String of application type - By default it is the value in preferences. + * In case there is any failure, for a specific type in handleAllSourcesCategories(), + * the string value is of that type. + * - ResultStatus : ResultStatus - by default is ResultStatus.OK. But in case there is a failure in + * any application category type, then it takes value of that failure. + * + * Issue: https://gitlab.e.foundation/e/backlog/-/issues/5413 + */ + suspend fun getCategoriesList(type: Category.Type, authData: AuthData): Triple, String, ResultStatus> { val categoriesList = mutableListOf() val preferredApplicationType = preferenceManagerModule.preferredApplicationType() + var apiStatus: ResultStatus = ResultStatus.OK + var applicationCategoryType = preferredApplicationType if (preferredApplicationType != APP_TYPE_ANY) { - handleCleanApkCategories(preferredApplicationType, categoriesList, type) + handleCleanApkCategories(preferredApplicationType, categoriesList, type).run { + if (this != ResultStatus.OK) { + apiStatus = this + } + } } else { - handleAllSourcesCategories(categoriesList, type, authData) + handleAllSourcesCategories(categoriesList, type, authData).run { + if (first != ResultStatus.OK) { + apiStatus = first + applicationCategoryType = second + } + } } categoriesList.sortBy { item -> item.title.lowercase() } - return categoriesList + return Triple(categoriesList, applicationCategoryType, apiStatus) } /** @@ -182,47 +211,136 @@ class FusedAPIImpl @Inject constructor( * @return A livedata list of non-nullable [FusedApp]. * Observe this livedata to display new apps as they are fetched from the network. */ - fun getSearchResults(query: String, authData: AuthData): LiveData> { + fun getSearchResults(query: String, authData: AuthData): LiveData>> { /* * Returning livedata to improve performance, so that we do not have to wait forever * for all results to be fetched from network before showing them. * Issue: https://gitlab.e.foundation/e/backlog/-/issues/5171 */ return liveData { + val packageSpecificResults = ArrayList() + + val status = runCodeBlockWithTimeout({ + if (preferenceManagerModule.preferredApplicationType() == APP_TYPE_ANY) { + try { + /* + * Surrounding with try-catch because if query is not a package name, + * then GPlay throws an error. + */ + getApplicationDetails(query, query, authData, Origin.GPLAY).let { + if (it.second == ResultStatus.OK) { + packageSpecificResults.add(it.first) + } + } + } catch (_: Exception) {} + } + getCleanapkSearchResult(query).let { + /* Cleanapk always returns something, it is never null. + * If nothing is found, it returns a blank FusedApp() object. + * Blank result to be filtered out. + */ + if (it.isSuccess() && it.data!!.package_name.isNotBlank()) { + packageSpecificResults.add(it.data!!) + } + } + }) + + /* + * If there was a timeout, return it and don't try to fetch anything else. + */ + if (status != ResultStatus.OK) { + emit(ResultSupreme.create(status, packageSpecificResults)) + return@liveData + } + + /* + * The list packageSpecificResults may contain apps with duplicate package names. + * Example, "org.telegram.messenger" will result in "Telegram" app from Play Store + * and "Telegram FOSS" from F-droid. We show both of them at the top. + * + * But for the other keyword related search results, we do not allow duplicate package names. + * We also filter out apps which are already present in packageSpecificResults list. + */ + fun filterWithKeywordSearch(list: List): List { + val filteredResults = list.distinctBy { it.package_name } + .filter { packageSpecificResults.isEmpty() || it.package_name != query } + return packageSpecificResults + filteredResults + } + + val cleanApkResults = mutableListOf() when (preferenceManagerModule.preferredApplicationType()) { APP_TYPE_ANY -> { - val cleanApkResults = getCleanAPKSearchResults(query) - if (cleanApkResults.isNotEmpty()) { + val status = runCodeBlockWithTimeout({ + cleanApkResults.addAll(getCleanAPKSearchResults(query)) + }) + if (cleanApkResults.isNotEmpty() || status != ResultStatus.OK) { /* * If cleanapk results are empty, dont emit emit data as it may * briefly show "No apps found..." + * If status is timeout, then do emit the value. */ - emit(cleanApkResults) + emit(ResultSupreme.create(status, filterWithKeywordSearch(cleanApkResults))) } - emitSource(getGplayAndCleanapkCombinedResults(query, authData, cleanApkResults)) + emitSource( + getGplayAndCleanapkCombinedResults(query, authData, cleanApkResults).map { + /* + * We are assuming that there will be no timeout here. + * If there had to be any timeout, it would already have happened + * while fetching package specific results. + */ + ResultSupreme.Success(filterWithKeywordSearch(it)) + } + ) } APP_TYPE_OPEN -> { - emit(getCleanAPKSearchResults(query)) + val status = runCodeBlockWithTimeout({ + cleanApkResults.addAll(getCleanAPKSearchResults(query)) + }) + emit(ResultSupreme.create(status, filterWithKeywordSearch(cleanApkResults))) } APP_TYPE_PWA -> { - emit( - getCleanAPKSearchResults( - query, - CleanAPKInterface.APP_SOURCE_ANY, - CleanAPKInterface.APP_TYPE_PWA + val status = runCodeBlockWithTimeout({ + cleanApkResults.addAll( + getCleanAPKSearchResults( + query, + CleanAPKInterface.APP_SOURCE_ANY, + CleanAPKInterface.APP_TYPE_PWA + ) ) - ) + }) + emit(ResultSupreme.create(status, cleanApkResults)) } } } + } + /* + * Method to search cleanapk based on package name. + * This is to be only used for showing an entry in search results list. + * DO NOT use this to show info on ApplicationFragment as it will not have all the required + * information to show for an app. + * + * Issue: https://gitlab.e.foundation/e/backlog/-/issues/2629 + */ + private suspend fun getCleanapkSearchResult(packageName: String): ResultSupreme { + var fusedApp = FusedApp() + val status = runCodeBlockWithTimeout({ + val result = cleanAPKRepository.searchApps( + keyword = packageName, + by = "package_name" + ).body() + if (result?.apps?.isNotEmpty() == true && result.numberOfResults == 1) { + fusedApp = result.apps[0] + } + }) + return ResultSupreme.create(status, fusedApp) } suspend fun getSearchSuggestions(query: String, authData: AuthData): List { return gPlayAPIRepository.getSearchSuggestions(query, authData) } - suspend fun fetchAuthData(): Unit? { + suspend fun fetchAuthData(): Boolean { return gPlayAPIRepository.fetchAuthData() } @@ -261,85 +379,157 @@ 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) - } + suspend fun getPWAApps(category: String): ResultSupreme> { + val list = mutableListOf() + val status = runCodeBlockWithTimeout({ + val response = getPWAAppsResponse(category) response?.apps?.forEach { it.updateStatus() it.updateType() + list.add(it) } - return response?.apps - } else { - val listApps = gPlayAPIRepository.listApps(browseUrl, authData) - return listApps.map { app -> - app.transformToFusedApp() - } - } + }) + return ResultSupreme.create(status, list) } - suspend fun getPWAApps(category: String): List? { - val response = getPWAAppsResponse(category) - response?.apps?.forEach { - it.updateStatus() - it.updateType() - } - return response?.apps + suspend fun getOpenSourceApps(category: String): ResultSupreme> { + val list = mutableListOf() + val status = runCodeBlockWithTimeout({ + val response = getOpenSourceAppsResponse(category) + response?.apps?.forEach { + it.updateStatus() + it.updateType() + list.add(it) + } + }) + return ResultSupreme.create(status, list) } - suspend fun getOpenSourceApps(category: String): List? { - val response = getOpenSourceAppsResponse(category) - response?.apps?.forEach { - it.updateStatus() - it.updateType() - } - return response?.apps + suspend fun getNextStreamBundle( + authData: AuthData, + homeUrl: String, + currentStreamBundle: StreamBundle, + ): ResultSupreme { + var streamBundle = StreamBundle() + val status = runCodeBlockWithTimeout({ + streamBundle = gPlayAPIRepository.getNextStreamBundle(authData, homeUrl, currentStreamBundle) + }) + return ResultSupreme.create(status, streamBundle) } - suspend fun getPlayStoreApps(browseUrl: String, authData: AuthData): List { - return gPlayAPIRepository.listApps(browseUrl, authData).map { app -> - app.transformToFusedApp() - } + 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 getPlayStoreAppCategoryUrls(browseUrl: String, authData: AuthData): List { - return gPlayAPIRepository.listAppCategoryUrls(browseUrl, authData) + suspend fun getNextStreamCluster( + authData: AuthData, + currentStreamCluster: StreamCluster, + ): ResultSupreme { + var streamCluster = StreamCluster() + val status = runCodeBlockWithTimeout({ + streamCluster = gPlayAPIRepository.getNextStreamCluster(authData, currentStreamCluster) + }) + return ResultSupreme.create(status, streamCluster) } - suspend fun getAppsAndNextClusterUrl( - browseUrl: String, - authData: AuthData - ): Pair, String> { - return gPlayAPIRepository.getAppsAndNextClusterUrl(browseUrl, authData).let { - Pair(it.first.map { app -> app.transformToFusedApp() }, it.second) - } + 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( packageNameList: List, authData: AuthData, origin: Origin - ): List { + ): Pair, ResultStatus> { val list = mutableListOf() - val response = if (origin == Origin.CLEANAPK) { - val pkgList = mutableListOf() - packageNameList.forEach { - val result = cleanAPKRepository.searchApps( - keyword = it, + + val response: Pair, ResultStatus> = + if (origin == Origin.CLEANAPK) { + getAppDetailsListFromCleanapk(packageNameList) + } else { + getAppDetailsListFromGPlay(packageNameList, authData) + } + + response.first.forEach { + if (it.package_name.isNotBlank()) { + it.updateStatus() + it.updateType() + list.add(it) + } + } + + return Pair(list, response.second) + } + + /* + * Get app details of a list of apps from cleanapk. + * Returns list of FusedApp and ResultStatus - which will reflect timeout if even one app fails. + * Issue: https://gitlab.e.foundation/e/backlog/-/issues/5413 + */ + private suspend fun getAppDetailsListFromCleanapk( + packageNameList: List, + ): Pair, ResultStatus> { + var status = ResultStatus.OK + val fusedAppList = mutableListOf() + + /* + * Fetch result of each cleanapk search with separate timeout, + * i.e. check timeout for individual package query. + */ + for (packageName in packageNameList) { + status = runCodeBlockWithTimeout({ + cleanAPKRepository.searchApps( + keyword = packageName, by = "package_name" - ).body() - if (result?.apps?.isNotEmpty() == true && result.numberOfResults == 1) { - pkgList.add(result.apps[0]) + ).body()?.run { + if (apps.isNotEmpty() && numberOfResults == 1) { + fusedAppList.add(apps[0]) + } } + }) + + /* + * If status is not ok, immediately return. + */ + if (status != ResultStatus.OK) { + return Pair(fusedAppList, status) } - pkgList - } else { - gPlayAPIRepository.getAppDetails(packageNameList, authData).map { app -> + } + + return Pair(fusedAppList, status) + } + + /* + * Get app details of a list of apps from Google Play store. + * Issue: https://gitlab.e.foundation/e/backlog/-/issues/5413 + */ + private suspend fun getAppDetailsListFromGPlay( + packageNameList: List, + authData: AuthData, + ): Pair, ResultStatus> { + var fusedAppList = listOf() + + /* + * Old code moved from getApplicationDetails() + */ + val status = runCodeBlockWithTimeout({ + fusedAppList = gPlayAPIRepository.getAppDetails(packageNameList, authData).map { app -> /* * Some apps are restricted to locations. Example "com.skype.m2". * For restricted apps, check if it is possible to get their specific app info. @@ -357,15 +547,42 @@ class FusedAPIImpl @Inject constructor( app.transformToFusedApp() } } - } - response.forEach { - if (it.package_name.isNotBlank()) { - it.updateStatus() - it.updateType() - list.add(it) + }) + + 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 list + }) + + return ResultSupreme.create(status, filteredFusedApps) } suspend fun getApplicationDetails( @@ -373,18 +590,24 @@ class FusedAPIImpl @Inject constructor( packageName: String, authData: AuthData, origin: Origin - ): FusedApp { - val response = if (origin == Origin.CLEANAPK) { - cleanAPKRepository.getAppOrPWADetailsByID(id).body()?.app - } else { - val app = gPlayAPIRepository.getAppDetails(packageName, authData) - app?.transformToFusedApp() - } - response?.let { - it.updateStatus() - it.updateType() - } - return response ?: FusedApp() + ): Pair { + + var response: FusedApp? = null + + val status = runCodeBlockWithTimeout({ + response = if (origin == Origin.CLEANAPK) { + cleanAPKRepository.getAppOrPWADetailsByID(id).body()?.app + } else { + val app = gPlayAPIRepository.getAppDetails(packageName, authData) + app?.transformToFusedApp() + } + response?.let { + it.updateStatus() + it.updateType() + } + }) + + return Pair(response ?: FusedApp(), status) } /* @@ -395,18 +618,19 @@ class FusedAPIImpl @Inject constructor( preferredApplicationType: String, categoriesList: MutableList, type: Category.Type - ) { - val data = getCleanApkCategories(preferredApplicationType) - - data?.let { category -> - categoriesList.addAll( - getFusedCategoryBasedOnCategoryType( - category, - type, - getCategoryTag(preferredApplicationType) + ): ResultStatus { + return runCodeBlockWithTimeout({ + val data = getCleanApkCategories(preferredApplicationType) + data?.let { category -> + categoriesList.addAll( + getFusedCategoryBasedOnCategoryType( + category, + type, + getCategoryTag(preferredApplicationType) + ) ) - ) - } + } + }) } private fun getCategoryTag(preferredApplicationType: String): AppTag { @@ -425,35 +649,115 @@ class FusedAPIImpl @Inject constructor( } } + /* + * Function to populate a given category list, from all GPlay categories, open source categories, + * and PWAs. + * + * Returns: Pair of: + * - ResultStatus - by default ResultStatus.OK, but can be different in case of an error in any category. + * - String - Application category type having error. If no error, then blank string. + * + * Issue: https://gitlab.e.foundation/e/backlog/-/issues/5413 + */ private suspend fun handleAllSourcesCategories( categoriesList: MutableList, type: Category.Type, authData: AuthData - ) { - var data = getOpenSourceCategories() - data?.let { - categoriesList.addAll( - getFusedCategoryBasedOnCategoryType( - it, - type, - AppTag.OpenSource(context.getString(R.string.open_source)) + ): Pair { + var data: Categories? = null + var apiStatus = ResultStatus.OK + var errorApplicationCategory = "" + + /* + * Try within timeout limit for open source native apps categories. + */ + runCodeBlockWithTimeout({ + data = getOpenSourceCategories() + data?.let { + categoriesList.addAll( + getFusedCategoryBasedOnCategoryType( + it, + type, + AppTag.OpenSource(context.getString(R.string.open_source)) + ) ) - ) - } - data = getPWAsCategories() - data?.let { - categoriesList.addAll( - getFusedCategoryBasedOnCategoryType( - it, type, AppTag.PWA(context.getString(R.string.pwa)) + } + }, { + errorApplicationCategory = APP_TYPE_OPEN + apiStatus = ResultStatus.TIMEOUT + }, { + errorApplicationCategory = APP_TYPE_OPEN + apiStatus = ResultStatus.UNKNOWN + }) + + /* + * Try within timeout limit to get PWA categories + */ + runCodeBlockWithTimeout({ + data = getPWAsCategories() + data?.let { + categoriesList.addAll( + getFusedCategoryBasedOnCategoryType( + it, type, AppTag.PWA(context.getString(R.string.pwa)) + ) ) - ) - } - val playResponse = gPlayAPIRepository.getCategoriesList(type, authData).map { app -> - val category = app.transformToFusedCategory() - updateCategoryDrawable(category, app) - category + } + }, { + errorApplicationCategory = APP_TYPE_PWA + apiStatus = ResultStatus.TIMEOUT + }, { + errorApplicationCategory = APP_TYPE_PWA + apiStatus = ResultStatus.UNKNOWN + }) + + /* + * Try within timeout limit to get native app categories from Play Store + */ + runCodeBlockWithTimeout({ + val playResponse = gPlayAPIRepository.getCategoriesList(type, authData).map { app -> + val category = app.transformToFusedCategory() + updateCategoryDrawable(category, app) + category + } + categoriesList.addAll(playResponse) + }, { + errorApplicationCategory = APP_TYPE_ANY + apiStatus = ResultStatus.TIMEOUT + }, { + errorApplicationCategory = APP_TYPE_ANY + apiStatus = ResultStatus.UNKNOWN + }) + + return Pair(apiStatus, errorApplicationCategory) + } + + /** + * Run a block of code with timeout. Returns status. + * + * @param block Main block to execute within [timeoutDurationInMillis] limit. + * @param timeoutBlock Optional code to execute in case of timeout. + * @param exceptionBlock Optional code to execute in case of an exception other than timeout. + * + * @return Instance of [ResultStatus] based on whether [block] was executed within timeout limit. + */ + private suspend fun runCodeBlockWithTimeout( + block: suspend () -> Unit, + timeoutBlock: (() -> Unit)? = null, + exceptionBlock: (() -> Unit)? = null, + ): ResultStatus { + return try { + withTimeout(timeoutDurationInMillis) { + block() + } + ResultStatus.OK + } catch (e: TimeoutCancellationException) { + timeoutBlock?.invoke() + ResultStatus.TIMEOUT + } catch (e: Exception) { + e.printStackTrace() + exceptionBlock?.invoke() + ResultStatus.UNKNOWN } - categoriesList.addAll(playResponse) } private fun updateCategoryDrawable( @@ -769,7 +1073,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 1eada6324..829f15285 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 @@ -20,13 +20,18 @@ package foundation.e.apps.api.fused import androidx.lifecycle.LiveData 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.Origin +import foundation.e.apps.utils.enums.ResultStatus import foundation.e.apps.utils.enums.Status import javax.inject.Inject import javax.inject.Singleton @@ -35,7 +40,7 @@ import javax.inject.Singleton class FusedAPIRepository @Inject constructor( private val fusedAPIImpl: FusedAPIImpl ) { - suspend fun getHomeScreenData(authData: AuthData): Pair, String> { + suspend fun getHomeScreenData(authData: AuthData): Pair, ResultStatus> { return fusedAPIImpl.getHomeScreenData(authData) } @@ -43,6 +48,10 @@ class FusedAPIRepository @Inject constructor( return fusedAPIImpl.isFusedHomesEmpty(fusedHomes) } + fun getApplicationCategoryPreference(): String { + return fusedAPIImpl.getApplicationCategoryPreference() + } + suspend fun validateAuthData(authData: AuthData): Boolean { return fusedAPIImpl.validateAuthData(authData) } @@ -51,16 +60,23 @@ class FusedAPIRepository @Inject constructor( packageNameList: List, authData: AuthData, origin: Origin - ): List { + ): Pair, ResultStatus> { 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, authData: AuthData, origin: Origin - ): FusedApp { + ): Pair { return fusedAPIImpl.getApplicationDetails(id, packageName, authData, origin) } @@ -76,7 +92,7 @@ class FusedAPIRepository @Inject constructor( ) } - suspend fun getCategoriesList(type: Category.Type, authData: AuthData): List { + suspend fun getCategoriesList(type: Category.Type, authData: AuthData): Triple, String, ResultStatus> { return fusedAPIImpl.getCategoriesList(type, authData) } @@ -84,7 +100,7 @@ class FusedAPIRepository @Inject constructor( return fusedAPIImpl.getSearchSuggestions(query, authData) } - suspend fun fetchAuthData(): Unit? { + suspend fun fetchAuthData(): Boolean { return fusedAPIImpl.fetchAuthData() } @@ -92,20 +108,31 @@ class FusedAPIRepository @Inject constructor( return fusedAPIImpl.fetchAuthData(email, aasToken) } - fun getSearchResults(query: String, authData: AuthData): LiveData> { + fun getSearchResults(query: String, authData: AuthData): LiveData>> { 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): Pair, String> { - return fusedAPIImpl.getAppsAndNextClusterUrl(browseUrl, authData) + suspend fun getNextStreamCluster( + authData: AuthData, + currentStreamCluster: StreamCluster, + ): ResultSupreme { + return fusedAPIImpl.getNextStreamCluster(authData, currentStreamCluster) } suspend fun getAppsListBasedOnCategory( @@ -113,10 +140,10 @@ class FusedAPIRepository @Inject constructor( browseUrl: String, authData: AuthData, source: String - ): List { + ): ResultSupreme> { return when (source) { - "Open Source" -> fusedAPIImpl.getOpenSourceApps(category) ?: listOf() - "PWA" -> fusedAPIImpl.getPWAApps(category) ?: listOf() + "Open Source" -> fusedAPIImpl.getOpenSourceApps(category) + "PWA" -> fusedAPIImpl.getPWAApps(category) else -> fusedAPIImpl.getPlayStoreApps(browseUrl, authData) } } 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 d48829311..2193bfc6d 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 @@ -34,6 +35,7 @@ data class FusedApp( val last_modified: String = String(), val latest_version_code: Int = -1, val latest_version_number: String = String(), + val latest_downloaded_version: String = String(), val licence: String = String(), val name: String = String(), val other_images_path: List = emptyList(), @@ -64,4 +66,18 @@ data class FusedApp( * Issue: https://gitlab.e.foundation/e/backlog/-/issues/5136 */ var permsFromExodus: List = LIST_OF_NULL, + 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 af6ef1c5c..f2e84858c 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 @@ -53,12 +53,23 @@ class GPlayAPIImpl @Inject constructor( private val gPlayHttpClient: GPlayHttpClient ) { + /** + * Save auth data to preferences. + * Updated for network failures. + * Issue: + * https://gitlab.e.foundation/e/backlog/-/issues/5413 + * https://gitlab.e.foundation/e/backlog/-/issues/5404 + * + * @return true or false based on if the request was successful. + */ // TODO: DON'T HARDCODE DISPATCHERS IN ANY METHODS - suspend fun fetchAuthData() = withContext(Dispatchers.IO) { + suspend fun fetchAuthData(): Boolean = withContext(Dispatchers.IO) { val data = async { tokenRepository.getAuthData() } - data.await()?.let { + data.await().let { + if (it == null) return@withContext false it.locale = context.resources.configuration.locales[0] // update locale with the default locale from settings dataStoreModule.saveCredentials(it) + return@withContext true } } @@ -69,8 +80,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 } @@ -171,69 +187,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 f268a4f9b..d9713f321 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 @@ -24,6 +24,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 @@ -31,7 +33,7 @@ class GPlayAPIRepository @Inject constructor( private val gPlayAPIImpl: GPlayAPIImpl ) { - suspend fun fetchAuthData(): Unit? { + suspend fun fetchAuthData(): Boolean { return gPlayAPIImpl.fetchAuthData() } @@ -80,15 +82,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 14aef8e72..08db545a1 100644 --- a/app/src/main/java/foundation/e/apps/application/ApplicationFragment.kt +++ b/app/src/main/java/foundation/e/apps/application/ApplicationFragment.kt @@ -31,7 +31,6 @@ import android.widget.RelativeLayout import androidx.core.content.ContextCompat import androidx.core.graphics.BlendModeColorFilterCompat import androidx.core.graphics.BlendModeCompat -import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.fragment.app.viewModels import androidx.lifecycle.lifecycleScope @@ -40,6 +39,7 @@ import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.navArgs import androidx.recyclerview.widget.LinearLayoutManager import coil.load +import com.aurora.gplayapi.data.models.AuthData import com.google.android.material.button.MaterialButton import com.google.android.material.snackbar.Snackbar import com.google.android.material.textview.MaterialTextView @@ -57,16 +57,18 @@ import foundation.e.apps.databinding.FragmentApplicationBinding import foundation.e.apps.manager.download.data.DownloadProgress import foundation.e.apps.manager.pkg.PkgManagerModule 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.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 @AndroidEntryPoint -class ApplicationFragment : Fragment(R.layout.fragment_application) { +class ApplicationFragment : TimeoutFragment(R.layout.fragment_application) { private val args: ApplicationFragmentArgs by navArgs() private val TAG = ApplicationFragment::class.java.simpleName @@ -98,17 +100,15 @@ class ApplicationFragment : Fragment(R.layout.fragment_application) { super.onViewCreated(view, savedInstanceState) _binding = FragmentApplicationBinding.bind(view) - mainActivityViewModel.internetConnection.observe(viewLifecycleOwner) { hasInternet -> - mainActivityViewModel.authData.observe(viewLifecycleOwner) { authData -> - if (hasInternet) { - applicationViewModel.getApplicationDetails( - args.id, - args.packageName, - authData, - args.origin - ) - } - } + /* + * Explanation of double observers in HomeFragment.kt + */ + + mainActivityViewModel.internetConnection.observe(viewLifecycleOwner) { + refreshDataOrRefreshToken(mainActivityViewModel) + } + mainActivityViewModel.authData.observe(viewLifecycleOwner) { + refreshDataOrRefreshToken(mainActivityViewModel) } val startDestination = findNavController().graph.startDestination @@ -137,7 +137,25 @@ class ApplicationFragment : Fragment(R.layout.fragment_application) { applicationViewModel.updateApplicationStatus(list) } - applicationViewModel.fusedApp.observe(viewLifecycleOwner) { + applicationViewModel.fusedApp.observe(viewLifecycleOwner) { resultPair -> + if (resultPair.second != ResultStatus.OK) { + onTimeout() + return@observe + } + + /* + * Previously fusedApp only had instance of FusedApp. + * As such previously all reference was simply using "it", the default variable in + * the scope. But now "it" is Pair(FusedApp, ResultStatus), not an instance of FusedApp. + * + * Avoid Git diffs by using a variable named "it". + * + * Issue: https://gitlab.e.foundation/e/backlog/-/issues/5413 + */ + val it = resultPair.first + + dismissTimeoutDialog() + if (applicationViewModel.appStatus.value == null) { applicationViewModel.appStatus.value = it.status } @@ -209,7 +227,7 @@ class ApplicationFragment : Fragment(R.layout.fragment_application) { binding.infoInclude.apply { appUpdatedOn.text = getString( R.string.updated_on, - if (args.origin == Origin.CLEANAPK) getString(R.string.not_available) else it.last_modified + if (args.origin == Origin.CLEANAPK) it.updatedOn else it.last_modified ) appRequires.text = getString(R.string.min_android_version, notAvailable) appVersion.text = getString( @@ -233,7 +251,7 @@ class ApplicationFragment : Fragment(R.layout.fragment_application) { ).show(childFragmentManager, TAG) } appTrackers.setOnClickListener { - val fusedApp = applicationViewModel.fusedApp.value + val fusedApp = applicationViewModel.fusedApp.value?.first var trackers = privacyInfoViewModel.getTrackerListText(fusedApp) @@ -259,8 +277,6 @@ class ApplicationFragment : Fragment(R.layout.fragment_application) { if (appInfoFetchViewModel.isAppInBlockedList(it)) { binding.snackbarLayout.visibility = View.VISIBLE } - - observeDownloadStatus(view) fetchAppTracker(it) } @@ -269,12 +285,44 @@ class ApplicationFragment : Fragment(R.layout.fragment_application) { } } + override fun onTimeout() { + if (!isTimeoutDialogDisplayed()) { + stopLoadingUI() + displayTimeoutAlertDialog( + timeoutFragment = this, + activity = requireActivity(), + message = getString(R.string.timeout_desc_cleanapk), + positiveButtonText = getString(R.string.retry), + positiveButtonBlock = { + showLoadingUI() + resetTimeoutDialogLock() + mainActivityViewModel.retryFetchingTokenAfterTimeout() + }, + negativeButtonText = getString(android.R.string.ok), + negativeButtonBlock = { + requireActivity().onBackPressed() + }, + allowCancel = false, + ) + } + } + + override fun refreshData(authData: AuthData) { + showLoadingUI() + applicationViewModel.getApplicationDetails( + args.id, + args.packageName, + authData, + args.origin + ) + } + private fun observeDownloadStatus(view: View) { applicationViewModel.appStatus.observe(viewLifecycleOwner) { status -> val installButton = binding.downloadInclude.installButton val downloadPB = binding.downloadInclude.progressLayout val appSize = binding.downloadInclude.appSize - val fusedApp = applicationViewModel.fusedApp.value ?: FusedApp() + val fusedApp = applicationViewModel.fusedApp.value?.first ?: FusedApp() when (status) { Status.INSTALLED -> handleInstalled( @@ -323,6 +371,11 @@ class ApplicationFragment : Fragment(R.layout.fragment_application) { } } + override fun onResume() { + super.onResume() + observeDownloadStatus(binding.root) + } + private fun handleInstallingIssue( installButton: MaterialButton, fusedApp: FusedApp, @@ -558,13 +611,22 @@ class ApplicationFragment : Fragment(R.layout.fragment_application) { private fun fetchAppTracker(fusedApp: FusedApp) { privacyInfoViewModel.getAppPrivacyInfoLiveData(fusedApp).observe(viewLifecycleOwner) { updatePrivacyScore() - binding.applicationLayout.visibility = View.VISIBLE - binding.progressBar.visibility = View.GONE + stopLoadingUI() } } + private fun showLoadingUI() { + binding.applicationLayout.visibility = View.GONE + binding.progressBar.visibility = View.VISIBLE + } + + private fun stopLoadingUI() { + binding.applicationLayout.visibility = View.VISIBLE + binding.progressBar.visibility = View.GONE + } + private fun updatePrivacyScore() { - val privacyScore = privacyInfoViewModel.getPrivacyScore(applicationViewModel.fusedApp.value) + val privacyScore = privacyInfoViewModel.getPrivacyScore(applicationViewModel.fusedApp.value?.first) if (privacyScore != -1) { val appPrivacyScore = binding.ratingsInclude.appPrivacyScore appPrivacyScore.text = getString( diff --git a/app/src/main/java/foundation/e/apps/application/ApplicationViewModel.kt b/app/src/main/java/foundation/e/apps/application/ApplicationViewModel.kt index ea96457cb..f64c95618 100644 --- a/app/src/main/java/foundation/e/apps/application/ApplicationViewModel.kt +++ b/app/src/main/java/foundation/e/apps/application/ApplicationViewModel.kt @@ -33,6 +33,7 @@ import foundation.e.apps.manager.download.data.DownloadProgressLD import foundation.e.apps.manager.fused.FusedManagerRepository import foundation.e.apps.manager.pkg.PkgManagerModule import foundation.e.apps.utils.enums.Origin +import foundation.e.apps.utils.enums.ResultStatus import foundation.e.apps.utils.enums.Status import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -46,7 +47,7 @@ class ApplicationViewModel @Inject constructor( private val pkgManagerModule: PkgManagerModule ) : ViewModel() { - val fusedApp: MutableLiveData = MutableLiveData() + val fusedApp: MutableLiveData> = MutableLiveData() val appStatus: MutableLiveData = MutableLiveData() val downloadProgress = downloadProgressLD private val _errorMessageLiveData: MutableLiveData = MutableLiveData() @@ -73,7 +74,7 @@ class ApplicationViewModel @Inject constructor( fun transformPermsToString(): String { var permissionString = "" - fusedApp.value?.let { + fusedApp.value?.first?.let { // Filter list to only keep platform permissions val filteredList = it.perms.filter { it.startsWith("android.permission.") @@ -97,7 +98,7 @@ class ApplicationViewModel @Inject constructor( } suspend fun calculateProgress(progress: DownloadProgress): Pair { - fusedApp.value?.let { app -> + fusedApp.value?.first?.let { app -> val appDownload = fusedManagerRepository.getDownloadList() .singleOrNull { it.id.contentEquals(app._id) } val downloadingMap = progress.totalSizeBytes.filter { item -> @@ -114,7 +115,7 @@ class ApplicationViewModel @Inject constructor( } fun updateApplicationStatus(downloadList: List) { - fusedApp.value?.let { app -> + fusedApp.value?.first?.let { app -> val downloadingItem = downloadList.find { it.origin == app.origin && (it.packageName == app.package_name || it.id == app.package_name) } appStatus.value = 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 e0b206aa5..910357483 100644 --- a/app/src/main/java/foundation/e/apps/applicationlist/ApplicationListFragment.kt +++ b/app/src/main/java/foundation/e/apps/applicationlist/ApplicationListFragment.kt @@ -21,7 +21,6 @@ package foundation.e.apps.applicationlist 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 @@ -30,6 +29,7 @@ import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.navArgs import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView +import com.aurora.gplayapi.data.models.AuthData import dagger.hilt.android.AndroidEntryPoint import foundation.e.apps.AppInfoFetchViewModel import foundation.e.apps.AppProgressViewModel @@ -46,11 +46,12 @@ import foundation.e.apps.manager.pkg.PkgManagerModule import foundation.e.apps.utils.enums.Status import foundation.e.apps.utils.enums.User import foundation.e.apps.utils.modules.PWAManagerModule +import foundation.e.apps.utils.parentFragment.TimeoutFragment import kotlinx.coroutines.launch import javax.inject.Inject @AndroidEntryPoint -class ApplicationListFragment : Fragment(R.layout.fragment_application_list), FusedAPIInterface { +class ApplicationListFragment : TimeoutFragment(R.layout.fragment_application_list), FusedAPIInterface { private val args: ApplicationListFragmentArgs by navArgs() @@ -88,11 +89,16 @@ class ApplicationListFragment : Fragment(R.layout.fragment_application_list), Fu private fun observeDownloadList() { mainActivityViewModel.downloadList.observe(viewLifecycleOwner) { list -> - val appList = viewModel.appListLiveData.value?.toMutableList() - appList?.let { + val appList = viewModel.appListLiveData.value?.data?.toMutableList() ?: emptyList() + appList.let { mainActivityViewModel.updateStatusOfFusedApps(it, list) } - viewModel.appListLiveData.value = appList + + /* + * Done in one line, so that on Ctrl+click on appListLiveData, + * we can see that it is being updated here. + */ + viewModel.appListLiveData.apply { value?.setData(appList) } } } @@ -143,66 +149,129 @@ class ApplicationListFragment : Fragment(R.layout.fragment_application_list), Fu layoutManager = LinearLayoutManager(view?.context) } - appProgressViewModel.downloadProgress.observe(viewLifecycleOwner) { - updateProgressOfDownloadingItems(recyclerView, it) - } - viewModel.appListLiveData.observe(viewLifecycleOwner) { - listAdapter?.setData(it) - if (!isDownloadObserverAdded) { - observeDownloadList() - isDownloadObserverAdded = true + if (!it.isSuccess()) { + onTimeout() + } else { + listAdapter?.setData(it.data!!) + if (!isDownloadObserverAdded) { + observeDownloadList() + isDownloadObserverAdded = true + } } - binding.shimmerLayout.visibility = View.GONE - recyclerView.visibility = View.VISIBLE + stopLoadingUI() } - mainActivityViewModel.internetConnection.observe(viewLifecycleOwner) { isInternetConnection -> - mainActivityViewModel.authData.value?.let { authData -> - if (isInternetConnection) { - viewModel.getList( - args.category, - args.browseUrl, - authData, - args.source - ) + /* + * Explanation of double observers in HomeFragment.kt + */ + + mainActivityViewModel.internetConnection.observe(viewLifecycleOwner) { + refreshDataOrRefreshToken(mainActivityViewModel) + } + mainActivityViewModel.authData.observe(viewLifecycleOwner) { + refreshDataOrRefreshToken(mainActivityViewModel) + } + } + + override fun onTimeout() { + if (!isTimeoutDialogDisplayed()) { + stopLoadingUI() + displayTimeoutAlertDialog( + timeoutFragment = this, + activity = requireActivity(), + message = getString(R.string.timeout_desc_cleanapk), + positiveButtonText = getString(R.string.retry), + positiveButtonBlock = { + showLoadingUI() + resetTimeoutDialogLock() + mainActivityViewModel.retryFetchingTokenAfterTimeout() + }, + negativeButtonText = getString(android.R.string.ok), + negativeButtonBlock = {}, + allowCancel = true, + ) + } + } + + override fun refreshData(authData: AuthData) { + showLoadingUI() - if (args.source != "Open Source" && args.source != "PWA") { - /* - * For Play store apps we try to load more apps on reaching end of list. - * Source: https://stackoverflow.com/a/46342525 - */ - recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() { - override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) { - super.onScrollStateChanged(recyclerView, newState) - if (!recyclerView.canScrollVertically(1)) { - viewModel.getPlayStoreAppsOnScroll(args.browseUrl, authData) - } - } - }) + /* + * Code moved from onResume() + */ + + viewModel.getList( + args.category, + args.browseUrl, + authData, + args.source + ) + + if (args.source != "Open Source" && args.source != "PWA") { + /* + * For Play store apps we try to load more apps on reaching end of list. + * Source: https://stackoverflow.com/a/46342525 + */ + binding.recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() { + override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) { + super.onScrollStateChanged(recyclerView, newState) + if (!recyclerView.canScrollVertically(1)) { + 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) { + updateProgressOfDownloadingItems(binding.recyclerView, it) + } + } + + private fun showLoadingUI() { + binding.shimmerLayout.startShimmer() + binding.shimmerLayout.visibility = View.VISIBLE + binding.recyclerView.visibility = View.GONE + } + + private fun stopLoadingUI() { + binding.shimmerLayout.stopShimmer() + binding.shimmerLayout.visibility = View.GONE + binding.recyclerView.visibility = View.VISIBLE } private fun updateProgressOfDownloadingItems( recyclerView: RecyclerView, - it: DownloadProgress + downloadProgress: DownloadProgress ) { val adapter = recyclerView.adapter as ApplicationListRVAdapter lifecycleScope.launch { adapter.currentList.forEach { fusedApp -> if (fusedApp.status == Status.DOWNLOADING) { - val progress = appProgressViewModel.calculateProgress(fusedApp, it) - val downloadProgress = - ((progress.second / progress.first.toDouble()) * 100).toInt() + val progress = appProgressViewModel.calculateProgress(fusedApp, downloadProgress) + if (progress == -1) { + return@forEach + } val viewHolder = recyclerView.findViewHolderForAdapterPosition( adapter.currentList.indexOf(fusedApp) ) viewHolder?.let { (viewHolder as ApplicationListRVAdapter.ViewHolder).binding.installButton.text = - "$downloadProgress%" + "$progress%" } } } 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 cd710a731..0d689ef75 100644 --- a/app/src/main/java/foundation/e/apps/applicationlist/ApplicationListViewModel.kt +++ b/app/src/main/java/foundation/e/apps/applicationlist/ApplicationListViewModel.kt @@ -22,7 +22,10 @@ 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 @@ -35,127 +38,318 @@ class ApplicationListViewModel @Inject constructor( private val fusedAPIRepository: FusedAPIRepository ) : ViewModel() { - val appListLiveData: MutableLiveData> = 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 + + /** + * 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) { + 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) + } } - /* - * 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?.let { addAll(it) } - } + appListLiveData.postValue(appsListData) + } + } - /** - * 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() } + /** + * 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 + } + + 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) + /* + * 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. + */ + if (addPlaceHolderAppIfNeeded(result)) { + appListLiveData.postValue(result) + } - 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(newList) - nextClusterUrl = second // set the next "clusterNextPageUrl" + /* + * 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. + */ + if (result.isSuccess() && lastCount == newCount && canLoadMore()) { + loadMore(authData, browseUrl) } } } } - fun getList(category: String, browseUrl: String, authData: AuthData, source: String) { - if (appListLiveData.value?.isNotEmpty() == true) { - return + /** + * 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 } - viewModelScope.launch(Dispatchers.IO) { - val packageNames = fusedAPIRepository.getAppsListBasedOnCategory( - category, - browseUrl, - authData, - source - ).map { it.package_name } - - val applicationDetails = if (!source.contentEquals("PWA")) { - fusedAPIRepository.getApplicationDetails( - packageNames, authData, - getOrigin(source) - ) - } else { - fusedAPIRepository.getAppsListBasedOnCategory(category, browseUrl, authData, source) + } + + /** + * 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() + } - appListLiveData.postValue(applicationDetails) + /** + * 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 0f798fd8e..c6ed486a5 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() @@ -186,7 +209,7 @@ class ApplicationListRVAdapter( holder: ViewHolder ) { installButton.visibility = View.VISIBLE - showMore.visibility = View.GONE + showMore.visibility = View.INVISIBLE when (searchApp.status) { Status.INSTALLED -> { handleInstalled(view, searchApp) @@ -213,7 +236,7 @@ class ApplicationListRVAdapter( } private fun ApplicationListItemBinding.setupShowMoreButton() { - installButton.visibility = View.GONE + installButton.visibility = View.INVISIBLE showMore.visibility = View.VISIBLE } diff --git a/app/src/main/java/foundation/e/apps/categories/AppsFragment.kt b/app/src/main/java/foundation/e/apps/categories/AppsFragment.kt index 27ee9360d..0335553b1 100644 --- a/app/src/main/java/foundation/e/apps/categories/AppsFragment.kt +++ b/app/src/main/java/foundation/e/apps/categories/AppsFragment.kt @@ -20,19 +20,21 @@ package foundation.e.apps.categories import android.os.Bundle import android.view.View -import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.fragment.app.viewModels import androidx.recyclerview.widget.LinearLayoutManager +import com.aurora.gplayapi.data.models.AuthData import com.aurora.gplayapi.data.models.Category import dagger.hilt.android.AndroidEntryPoint import foundation.e.apps.MainActivityViewModel import foundation.e.apps.R import foundation.e.apps.categories.model.CategoriesRVAdapter import foundation.e.apps.databinding.FragmentAppsBinding +import foundation.e.apps.utils.enums.ResultStatus +import foundation.e.apps.utils.parentFragment.TimeoutFragment @AndroidEntryPoint -class AppsFragment : Fragment(R.layout.fragment_apps) { +class AppsFragment : TimeoutFragment(R.layout.fragment_apps) { private var _binding: FragmentAppsBinding? = null private val binding get() = _binding!! @@ -43,35 +45,83 @@ class AppsFragment : Fragment(R.layout.fragment_apps) { super.onViewCreated(view, savedInstanceState) _binding = FragmentAppsBinding.bind(view) - mainActivityViewModel.internetConnection.observe(viewLifecycleOwner) { hasInternet -> - mainActivityViewModel.authData.value?.let { authData -> - if (hasInternet) { - categoriesViewModel.getCategoriesList( - Category.Type.APPLICATION, - authData - ) - } - } + /* + * Explanation of double observers in HomeFragment.kt + */ - val categoriesRVAdapter = CategoriesRVAdapter() - val recyclerView = binding.recyclerView + mainActivityViewModel.internetConnection.observe(viewLifecycleOwner) { + refreshDataOrRefreshToken(mainActivityViewModel) + } + mainActivityViewModel.authData.observe(viewLifecycleOwner) { + refreshDataOrRefreshToken(mainActivityViewModel) + } - recyclerView.apply { - adapter = categoriesRVAdapter - layoutManager = LinearLayoutManager(context, LinearLayoutManager.VERTICAL, false) - visibility = View.GONE - } + /* + * Code regarding is just moved outside the observers. + * Issue: https://gitlab.e.foundation/e/backlog/-/issues/5413 + */ + + val categoriesRVAdapter = CategoriesRVAdapter() + val recyclerView = binding.recyclerView - categoriesViewModel.categoriesList.observe(viewLifecycleOwner) { - categoriesRVAdapter.setData(it) - binding.shimmerLayout.visibility = View.GONE - recyclerView.visibility = View.VISIBLE + recyclerView.apply { + adapter = categoriesRVAdapter + layoutManager = LinearLayoutManager(context, LinearLayoutManager.VERTICAL, false) + visibility = View.GONE + } + + categoriesViewModel.categoriesList.observe(viewLifecycleOwner) { + stopLoadingUI() + categoriesRVAdapter.setData(it.first) + if (it.third != ResultStatus.OK) { + onTimeout() } } } + override fun onTimeout() { + if (!isTimeoutDialogDisplayed()) { + stopLoadingUI() + displayTimeoutAlertDialog( + timeoutFragment = this, + activity = requireActivity(), + message = getString(R.string.timeout_desc_cleanapk), + positiveButtonText = getString(android.R.string.ok), + positiveButtonBlock = {}, + negativeButtonText = getString(R.string.retry), + negativeButtonBlock = { + showLoadingUI() + resetTimeoutDialogLock() + mainActivityViewModel.retryFetchingTokenAfterTimeout() + }, + allowCancel = true, + ) + } + } + + override fun refreshData(authData: AuthData) { + showLoadingUI() + categoriesViewModel.getCategoriesList( + Category.Type.APPLICATION, + authData + ) + } + + private fun showLoadingUI() { + binding.shimmerLayout.startShimmer() + binding.shimmerLayout.visibility = View.VISIBLE + binding.recyclerView.visibility = View.GONE + } + + private fun stopLoadingUI() { + binding.shimmerLayout.stopShimmer() + binding.shimmerLayout.visibility = View.GONE + binding.recyclerView.visibility = View.VISIBLE + } + override fun onResume() { super.onResume() + resetTimeoutDialogLock() binding.shimmerLayout.startShimmer() } diff --git a/app/src/main/java/foundation/e/apps/categories/CategoriesFragment.kt b/app/src/main/java/foundation/e/apps/categories/CategoriesFragment.kt index 59e949435..2fb4fc7c9 100644 --- a/app/src/main/java/foundation/e/apps/categories/CategoriesFragment.kt +++ b/app/src/main/java/foundation/e/apps/categories/CategoriesFragment.kt @@ -19,16 +19,18 @@ package foundation.e.apps.categories import android.os.Bundle +import android.util.Log import android.view.View -import androidx.fragment.app.Fragment +import com.aurora.gplayapi.data.models.AuthData import com.google.android.material.tabs.TabLayoutMediator import dagger.hilt.android.AndroidEntryPoint import foundation.e.apps.R import foundation.e.apps.categories.model.CategoriesVPAdapter import foundation.e.apps.databinding.FragmentCategoriesBinding +import foundation.e.apps.utils.parentFragment.TimeoutFragment @AndroidEntryPoint -class CategoriesFragment : Fragment(R.layout.fragment_categories) { +class CategoriesFragment : TimeoutFragment(R.layout.fragment_categories) { private var _binding: FragmentCategoriesBinding? = null private val binding get() = _binding!! @@ -53,4 +55,25 @@ class CategoriesFragment : Fragment(R.layout.fragment_categories) { super.onDestroyView() _binding = null } + + override fun onTimeout() { + val position = binding.viewPager.currentItem + + val fragment = childFragmentManager.fragments.find { + when (position) { + 0 -> it is AppsFragment + 1 -> it is GamesFragment + else -> false + } + } + + fragment?.let { + if (it is TimeoutFragment) { + Log.d(TAG, "Showing timeout on Categories fragment: " + it::class.java.name) + it.onTimeout() + } + } + } + + override fun refreshData(authData: AuthData) {} } diff --git a/app/src/main/java/foundation/e/apps/categories/CategoriesViewModel.kt b/app/src/main/java/foundation/e/apps/categories/CategoriesViewModel.kt index 100e63982..ec72f9150 100644 --- a/app/src/main/java/foundation/e/apps/categories/CategoriesViewModel.kt +++ b/app/src/main/java/foundation/e/apps/categories/CategoriesViewModel.kt @@ -26,6 +26,7 @@ import com.aurora.gplayapi.data.models.Category import dagger.hilt.android.lifecycle.HiltViewModel import foundation.e.apps.api.fused.FusedAPIRepository import foundation.e.apps.api.fused.data.FusedCategory +import foundation.e.apps.utils.enums.ResultStatus import kotlinx.coroutines.launch import javax.inject.Inject @@ -34,11 +35,16 @@ class CategoriesViewModel @Inject constructor( private val fusedAPIRepository: FusedAPIRepository ) : ViewModel() { - val categoriesList: MutableLiveData> = MutableLiveData() + val categoriesList: MutableLiveData, String, ResultStatus>> = + MutableLiveData() fun getCategoriesList(type: Category.Type, authData: AuthData) { viewModelScope.launch { categoriesList.postValue(fusedAPIRepository.getCategoriesList(type, authData)) } } + + fun isCategoriesEmpty(): Boolean { + return categoriesList.value?.first?.isEmpty() ?: true + } } diff --git a/app/src/main/java/foundation/e/apps/categories/GamesFragment.kt b/app/src/main/java/foundation/e/apps/categories/GamesFragment.kt index 320d23768..109451974 100644 --- a/app/src/main/java/foundation/e/apps/categories/GamesFragment.kt +++ b/app/src/main/java/foundation/e/apps/categories/GamesFragment.kt @@ -20,19 +20,21 @@ package foundation.e.apps.categories import android.os.Bundle import android.view.View -import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.fragment.app.viewModels import androidx.recyclerview.widget.LinearLayoutManager +import com.aurora.gplayapi.data.models.AuthData import com.aurora.gplayapi.data.models.Category import dagger.hilt.android.AndroidEntryPoint import foundation.e.apps.MainActivityViewModel import foundation.e.apps.R import foundation.e.apps.categories.model.CategoriesRVAdapter import foundation.e.apps.databinding.FragmentGamesBinding +import foundation.e.apps.utils.enums.ResultStatus +import foundation.e.apps.utils.parentFragment.TimeoutFragment @AndroidEntryPoint -class GamesFragment : Fragment(R.layout.fragment_games) { +class GamesFragment : TimeoutFragment(R.layout.fragment_games) { private var _binding: FragmentGamesBinding? = null private val binding get() = _binding!! @@ -43,15 +45,15 @@ class GamesFragment : Fragment(R.layout.fragment_games) { super.onViewCreated(view, savedInstanceState) _binding = FragmentGamesBinding.bind(view) - mainActivityViewModel.internetConnection.observe(viewLifecycleOwner) { hasInternet -> - mainActivityViewModel.authData.value?.let { authData -> - if (hasInternet) { - categoriesViewModel.getCategoriesList( - Category.Type.GAME, - authData - ) - } - } + /* + * Explanation of double observers in HomeFragment.kt + */ + + mainActivityViewModel.internetConnection.observe(viewLifecycleOwner) { + refreshDataOrRefreshToken(mainActivityViewModel) + } + mainActivityViewModel.authData.observe(viewLifecycleOwner) { + refreshDataOrRefreshToken(mainActivityViewModel) } val categoriesRVAdapter = CategoriesRVAdapter() @@ -64,14 +66,57 @@ class GamesFragment : Fragment(R.layout.fragment_games) { } categoriesViewModel.categoriesList.observe(viewLifecycleOwner) { - categoriesRVAdapter.setData(it) - binding.shimmerLayout.visibility = View.GONE - recyclerView.visibility = View.VISIBLE + stopLoadingUI() + categoriesRVAdapter.setData(it.first) + if (it.third != ResultStatus.OK) { + onTimeout() + } } } + override fun onTimeout() { + if (!isTimeoutDialogDisplayed()) { + stopLoadingUI() + displayTimeoutAlertDialog( + timeoutFragment = this, + activity = requireActivity(), + message = getString(R.string.timeout_desc_cleanapk), + positiveButtonText = getString(android.R.string.ok), + positiveButtonBlock = {}, + negativeButtonText = getString(R.string.retry), + negativeButtonBlock = { + showLoadingUI() + resetTimeoutDialogLock() + mainActivityViewModel.retryFetchingTokenAfterTimeout() + }, + allowCancel = true, + ) + } + } + + override fun refreshData(authData: AuthData) { + showLoadingUI() + categoriesViewModel.getCategoriesList( + Category.Type.GAME, + authData + ) + } + + private fun showLoadingUI() { + binding.shimmerLayout.startShimmer() + binding.shimmerLayout.visibility = View.VISIBLE + binding.recyclerView.visibility = View.GONE + } + + private fun stopLoadingUI() { + binding.shimmerLayout.stopShimmer() + binding.shimmerLayout.visibility = View.GONE + binding.recyclerView.visibility = View.VISIBLE + } + override fun onResume() { super.onResume() + resetTimeoutDialogLock() binding.shimmerLayout.startShimmer() } 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 0516c3469..69b9a2fc7 100644 --- a/app/src/main/java/foundation/e/apps/home/HomeFragment.kt +++ b/app/src/main/java/foundation/e/apps/home/HomeFragment.kt @@ -21,18 +21,19 @@ 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 import androidx.navigation.findNavController import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView +import com.aurora.gplayapi.data.models.AuthData import dagger.hilt.android.AndroidEntryPoint import foundation.e.apps.AppInfoFetchViewModel import foundation.e.apps.AppProgressViewModel import foundation.e.apps.MainActivityViewModel import foundation.e.apps.R +import foundation.e.apps.api.fused.FusedAPIImpl import foundation.e.apps.api.fused.FusedAPIInterface import foundation.e.apps.api.fused.data.FusedApp import foundation.e.apps.application.subFrags.ApplicationDialogFragment @@ -41,16 +42,19 @@ import foundation.e.apps.home.model.HomeChildRVAdapter import foundation.e.apps.home.model.HomeParentRVAdapter 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.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 @AndroidEntryPoint -class HomeFragment : Fragment(R.layout.fragment_home), FusedAPIInterface { +class HomeFragment : TimeoutFragment(R.layout.fragment_home), FusedAPIInterface { + private lateinit var homeParentRVAdapter: HomeParentRVAdapter private var _binding: FragmentHomeBinding? = null private val binding get() = _binding!! @@ -77,13 +81,52 @@ class HomeFragment : Fragment(R.layout.fragment_home), FusedAPIInterface { } } + /* + * Previous code: + * internetConnection.observe { + * authData.observe { + * // refresh data here. + * } + * } + * + * Code regarding data fetch is placed in two separate observers compared to nested + * observers as was done previously. + * + * refreshDataOrRefreshToken() already checks for internet connectivity and authData. + * If authData is null, it requests to fetch new token data. + * + * With previous nested observer code (commit 8ca1647d), try the following: + * 1. Put garbage value in "Proxy" of APN settings of device, + * this will cause host unreachable error. + * 2. Open App Lounge. Let it show timeout dialog. + * 3. Click "Open Settings", now immediately open Home tab again. + * 4. Home keeps loading without any timeout error. + * + * Why is this happening? + * In case of host unreachable error, the authData is itself blank/null. This does not allow + * it to get "observed". But mainActivityViewModel.internetConnection always has a value, + * and is observable. + * When we open Home tab again from Settings tab, no refresh action is performed as + * authData.observe {} does not observe anything. + * + * In the new code, the first observer will always be executed on fragment attach + * (as mainActivityViewModel.internetConnection always has a value and is observable), + * this will call refreshDataOrRefreshToken(), which will refresh authData if it is null. + * Now with new valid authData, the second observer (authData.observe{}) will again call + * refreshDataOrRefreshToken() which will now fetch correct data. + * + * + * Issue: https://gitlab.e.foundation/e/backlog/-/issues/5413 + */ + mainActivityViewModel.internetConnection.observe(viewLifecycleOwner) { - mainActivityViewModel.authData.observe(viewLifecycleOwner) { - refreshHomeData() - } + refreshDataOrRefreshToken(mainActivityViewModel) + } + mainActivityViewModel.authData.observe(viewLifecycleOwner) { + refreshDataOrRefreshToken(mainActivityViewModel) } - val homeParentRVAdapter = HomeParentRVAdapter( + homeParentRVAdapter = HomeParentRVAdapter( this, pkgManagerModule, pwaManagerModule, @@ -109,43 +152,64 @@ class HomeFragment : Fragment(R.layout.fragment_home), FusedAPIInterface { } homeViewModel.homeScreenData.observe(viewLifecycleOwner) { - binding.shimmerLayout.visibility = View.GONE - binding.parentRV.visibility = View.VISIBLE - if (!homeViewModel.isFusedHomesEmpty(it.first)) { + stopLoadingUI() + if (it.second == ResultStatus.OK) { + dismissTimeoutDialog() homeParentRVAdapter.setData(it.first) - } else if (!mainActivityViewModel.isTimeoutDialogDisplayed()) { - mainActivityViewModel.uploadFaultyTokenToEcloud("From " + this::class.java.name) - mainActivityViewModel.displayTimeoutAlertDialog(requireActivity(), { - showLoadingShimmer() - mainActivityViewModel.retryFetchingTokenAfterTimeout() - }, { - openSettings() - }, it.second) + } else { + onTimeout() } } + } - appProgressViewModel.downloadProgress.observe(viewLifecycleOwner) { - updateProgressOfDownloadingAppItemViews(homeParentRVAdapter, it) + override fun onTimeout() { + if (homeViewModel.isFusedHomesEmpty() && !isTimeoutDialogDisplayed()) { + mainActivityViewModel.uploadFaultyTokenToEcloud("From " + this::class.java.name) + stopLoadingUI() + displayTimeoutAlertDialog( + timeoutFragment = this, + activity = requireActivity(), + message = + if (homeViewModel.getApplicationCategoryPreference() == FusedAPIImpl.APP_TYPE_ANY) { + getString(R.string.timeout_desc_gplay) + } else { + getString(R.string.timeout_desc_cleanapk) + }, + positiveButtonText = getString(R.string.retry), + positiveButtonBlock = { + showLoadingUI() + resetTimeoutDialogLock() + mainActivityViewModel.retryFetchingTokenAfterTimeout() + }, + negativeButtonText = + if (homeViewModel.getApplicationCategoryPreference() == FusedAPIImpl.APP_TYPE_ANY) { + getString(R.string.open_settings) + } else null, + negativeButtonBlock = { + openSettings() + }, + allowCancel = false, + ) } } - /* - * Offload loading home data to a different function, to allow retrying mechanism. - */ - private fun refreshHomeData() { - if (mainActivityViewModel.internetConnection.value == true) { - mainActivityViewModel.authData.value?.let { authData -> - mainActivityViewModel.dismissTimeoutDialog() - homeViewModel.getHomeScreenData(authData) - } - } + override fun refreshData(authData: AuthData) { + showLoadingUI() + homeViewModel.getHomeScreenData(authData) } - private fun showLoadingShimmer() { + private fun showLoadingUI() { + binding.shimmerLayout.startShimmer() binding.shimmerLayout.visibility = View.VISIBLE binding.parentRV.visibility = View.GONE } + private fun stopLoadingUI() { + binding.shimmerLayout.stopShimmer() + binding.shimmerLayout.visibility = View.GONE + binding.parentRV.visibility = View.VISIBLE + } + private fun updateProgressOfDownloadingAppItemViews( homeParentRVAdapter: HomeParentRVAdapter, downloadProgress: DownloadProgress @@ -173,14 +237,15 @@ class HomeFragment : Fragment(R.layout.fragment_home), FusedAPIInterface { if (fusedApp.status == Status.DOWNLOADING) { val progress = appProgressViewModel.calculateProgress(fusedApp, downloadProgress) - val downloadProgress = - ((progress.second / progress.first.toDouble()) * 100).toInt() + if (progress == -1) { + return@forEach + } val childViewHolder = childRV.findViewHolderForAdapterPosition( adapter.currentList.indexOf(fusedApp) ) childViewHolder?.let { (childViewHolder as HomeChildRVAdapter.ViewHolder).binding.installButton.text = - "$downloadProgress%" + "$progress%" } } } @@ -189,7 +254,11 @@ class HomeFragment : Fragment(R.layout.fragment_home), FusedAPIInterface { override fun onResume() { super.onResume() + resetTimeoutDialogLock() binding.shimmerLayout.startShimmer() + appProgressViewModel.downloadProgress.observe(viewLifecycleOwner) { + updateProgressOfDownloadingAppItemViews(homeParentRVAdapter, it) + } } override fun onPause() { diff --git a/app/src/main/java/foundation/e/apps/home/HomeViewModel.kt b/app/src/main/java/foundation/e/apps/home/HomeViewModel.kt index 48e020bee..5e20a1033 100644 --- a/app/src/main/java/foundation/e/apps/home/HomeViewModel.kt +++ b/app/src/main/java/foundation/e/apps/home/HomeViewModel.kt @@ -25,6 +25,7 @@ import com.aurora.gplayapi.data.models.AuthData import dagger.hilt.android.lifecycle.HiltViewModel import foundation.e.apps.api.fused.FusedAPIRepository import foundation.e.apps.api.fused.data.FusedHome +import foundation.e.apps.utils.enums.ResultStatus import kotlinx.coroutines.launch import javax.inject.Inject @@ -39,7 +40,7 @@ class HomeViewModel @Inject constructor( * * Issue: https://gitlab.e.foundation/e/backlog/-/issues/5404 */ - var homeScreenData: MutableLiveData, String>> = MutableLiveData() + var homeScreenData: MutableLiveData, ResultStatus>> = MutableLiveData() fun getHomeScreenData(authData: AuthData) { viewModelScope.launch { @@ -47,7 +48,13 @@ class HomeViewModel @Inject constructor( } } - fun isFusedHomesEmpty(fusedHomes: List): Boolean { - return fusedAPIRepository.isFusedHomesEmpty(fusedHomes) + fun getApplicationCategoryPreference(): String { + return fusedAPIRepository.getApplicationCategoryPreference() + } + + fun isFusedHomesEmpty(): Boolean { + return homeScreenData.value?.first?.let { + fusedAPIRepository.isFusedHomesEmpty(it) + } ?: true } } diff --git a/app/src/main/java/foundation/e/apps/manager/database/DatabaseModule.kt b/app/src/main/java/foundation/e/apps/manager/database/DatabaseModule.kt index 678c91003..d2cf3fd03 100644 --- a/app/src/main/java/foundation/e/apps/manager/database/DatabaseModule.kt +++ b/app/src/main/java/foundation/e/apps/manager/database/DatabaseModule.kt @@ -1,7 +1,6 @@ package foundation.e.apps.manager.database import android.content.Context -import androidx.room.Room import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -13,17 +12,10 @@ import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) object DatabaseModule { - - private const val DATABASE_NAME = "fused_database" - @Singleton @Provides fun provideDatabaseInstance(@ApplicationContext context: Context): FusedDatabase { - return Room.databaseBuilder( - context, - FusedDatabase::class.java, - DATABASE_NAME - ).fallbackToDestructiveMigration().build() + return FusedDatabase.getInstance(context) } @Singleton diff --git a/app/src/main/java/foundation/e/apps/manager/database/FusedDatabase.kt b/app/src/main/java/foundation/e/apps/manager/database/FusedDatabase.kt index 51f106117..c8b4c080c 100644 --- a/app/src/main/java/foundation/e/apps/manager/database/FusedDatabase.kt +++ b/app/src/main/java/foundation/e/apps/manager/database/FusedDatabase.kt @@ -1,14 +1,33 @@ package foundation.e.apps.manager.database +import android.content.Context import androidx.room.Database +import androidx.room.Room import androidx.room.RoomDatabase import androidx.room.TypeConverters +import foundation.e.apps.api.database.AppDatabase import foundation.e.apps.manager.database.fusedDownload.FusedDownload import foundation.e.apps.manager.database.fusedDownload.FusedDownloadDAO @Database(entities = [FusedDownload::class], version = 2, exportSchema = false) @TypeConverters(FusedConverter::class) abstract class FusedDatabase : RoomDatabase() { - abstract fun fusedDownloadDao(): FusedDownloadDAO + + companion object { + private lateinit var INSTANCE: FusedDatabase + private const val DATABASE_NAME = "fused_database" + + fun getInstance(context: Context): FusedDatabase { + if (!Companion::INSTANCE.isInitialized) { + synchronized(AppDatabase::class) { + INSTANCE = + Room.databaseBuilder(context, FusedDatabase::class.java, DATABASE_NAME) + .fallbackToDestructiveMigration() + .build() + } + } + return INSTANCE + } + } } diff --git a/app/src/main/java/foundation/e/apps/manager/download/DownloadManagerUtils.kt b/app/src/main/java/foundation/e/apps/manager/download/DownloadManagerUtils.kt index e85f97965..2b27368aa 100644 --- a/app/src/main/java/foundation/e/apps/manager/download/DownloadManagerUtils.kt +++ b/app/src/main/java/foundation/e/apps/manager/download/DownloadManagerUtils.kt @@ -1,6 +1,6 @@ /* + * Copyright ECORP SAS 2022 * Apps Quickly and easily install Android apps onto your device! - * Copyright (C) 2021 E FOUNDATION * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -64,10 +64,8 @@ class DownloadManagerUtils @Inject constructor( ) if (downloaded == fusedDownload.downloadIdMap.size) { fusedManagerRepository.moveOBBFileToOBBDirectory(fusedDownload) - fusedManagerRepository.updateDownloadStatus( - fusedDownload, - Status.INSTALLING - ) + fusedDownload.status = Status.DOWNLOADED + fusedManagerRepository.updateFusedDownload(fusedDownload) } } } diff --git a/app/src/main/java/foundation/e/apps/manager/download/data/DownloadProgress.kt b/app/src/main/java/foundation/e/apps/manager/download/data/DownloadProgress.kt index 781891810..4df4506e4 100644 --- a/app/src/main/java/foundation/e/apps/manager/download/data/DownloadProgress.kt +++ b/app/src/main/java/foundation/e/apps/manager/download/data/DownloadProgress.kt @@ -3,5 +3,6 @@ package foundation.e.apps.manager.download.data data class DownloadProgress( var totalSizeBytes: MutableMap = mutableMapOf(), var bytesDownloadedSoFar: MutableMap = mutableMapOf(), - var status: MutableMap = mutableMapOf() + var status: MutableMap = mutableMapOf(), + var downloadId: Long = -1 ) 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 f461c1b56..e768b25ef 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 @@ -31,12 +30,11 @@ class DownloadProgressLD @Inject constructor( override fun observe(owner: LifecycleOwner, observer: Observer) { job = Job() super.observe(owner, observer) - } - override fun onActive() { - super.onActive() + val hasActiveObservers = hasActiveObservers() + launch { - while (isActive) { + while (hasActiveObservers) { val downloads = fusedManagerRepository.getDownloadList() val downloadingList = downloads.map { it.downloadIdMap }.filter { it.values.contains(false) } @@ -60,6 +58,8 @@ class DownloadProgressLD @Inject constructor( val bytesDownloadedSoFar = cursor.getLong(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR)) + downloadProgress.downloadId = id + if (!downloadProgress.totalSizeBytes.containsKey(id) || downloadProgress.totalSizeBytes[id] != totalSizeBytes ) { @@ -80,7 +80,6 @@ class DownloadProgressLD @Inject constructor( } if (downloadingIds.isEmpty()) { - clearDownload() cancel() } cursor.moveToNext() diff --git a/app/src/main/java/foundation/e/apps/manager/fused/FusedManagerImpl.kt b/app/src/main/java/foundation/e/apps/manager/fused/FusedManagerImpl.kt index cc62ccae9..86c0f9978 100644 --- a/app/src/main/java/foundation/e/apps/manager/fused/FusedManagerImpl.kt +++ b/app/src/main/java/foundation/e/apps/manager/fused/FusedManagerImpl.kt @@ -1,3 +1,21 @@ +/* + * Copyright ECORP SAS 2022 + * Apps Quickly and easily install Android apps onto your device! + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + package foundation.e.apps.manager.fused import android.app.DownloadManager @@ -114,7 +132,9 @@ class FusedManagerImpl @Inject constructor( pkgManagerModule.installApplication(list, fusedDownload.packageName) Log.d(TAG, "installApp: ENDED ${fusedDownload.name} ${list.size}") } catch (e: Exception) { + Log.d(TAG, ">>> installApp app failed ") installationIssue(fusedDownload) + throw e } } } diff --git a/app/src/main/java/foundation/e/apps/manager/pkg/InstallerService.kt b/app/src/main/java/foundation/e/apps/manager/pkg/InstallerService.kt index 236dea7d2..1ec727682 100644 --- a/app/src/main/java/foundation/e/apps/manager/pkg/InstallerService.kt +++ b/app/src/main/java/foundation/e/apps/manager/pkg/InstallerService.kt @@ -41,6 +41,10 @@ class InstallerService : Service() { @Inject lateinit var pkgManagerModule: PkgManagerModule + companion object { + const val TAG = "InstallerService" + } + @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP) override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int { val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, -69) @@ -52,7 +56,7 @@ class InstallerService : Service() { } private fun postStatus(status: Int, packageName: String?, extra: String?) { - Log.d("InstallerService", "postStatus: $status $packageName $extra") + Log.d(TAG, "postStatus: $status $packageName $extra") if (status != PackageInstaller.STATUS_SUCCESS) { updateInstallationIssue(packageName ?: "") } @@ -62,20 +66,9 @@ class InstallerService : Service() { return null } - private fun updateDownloadStatus(pkgName: String) { - if (pkgName.isEmpty()) { - Log.d("PkgManagerBR", "updateDownloadStatus: package name should not be empty!") - } - GlobalScope.launch { - val fusedDownload = fusedManagerRepository.getFusedDownload(packageName = pkgName) - pkgManagerModule.setFakeStoreAsInstallerIfNeeded(fusedDownload) - fusedManagerRepository.updateDownloadStatus(fusedDownload, Status.INSTALLED) - } - } - private fun updateInstallationIssue(pkgName: String) { if (pkgName.isEmpty()) { - Log.d("PkgManagerBR", "updateDownloadStatus: package name should not be empty!") + Log.d(TAG, "updateDownloadStatus: package name should not be empty!") } GlobalScope.launch { val fusedDownload = fusedManagerRepository.getFusedDownload(packageName = pkgName) diff --git a/app/src/main/java/foundation/e/apps/manager/workmanager/InstallAppWorker.kt b/app/src/main/java/foundation/e/apps/manager/workmanager/InstallAppWorker.kt index 61febf65e..39ee678c9 100644 --- a/app/src/main/java/foundation/e/apps/manager/workmanager/InstallAppWorker.kt +++ b/app/src/main/java/foundation/e/apps/manager/workmanager/InstallAppWorker.kt @@ -1,3 +1,21 @@ +/* + * Copyright ECORP SAS 2022 + * Apps Quickly and easily install Android apps onto your device! + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + package foundation.e.apps.manager.workmanager import android.app.DownloadManager @@ -26,7 +44,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.flow.takeWhile +import kotlinx.coroutines.sync.Mutex import java.util.concurrent.atomic.AtomicInteger import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds @@ -59,15 +77,20 @@ class InstallAppWorker @AssistedInject constructor( private val atomicInteger = AtomicInteger(100) } + private val mutex = Mutex(true) + override suspend fun doWork(): Result { var fusedDownload: FusedDownload? = null try { val fusedDownloadString = params.inputData.getString(INPUT_DATA_FUSED_DOWNLOAD) ?: "" - Log.d(TAG, "Fused download name $fusedDownloadString") fusedDownload = databaseRepository.getDownloadById(fusedDownloadString) + Log.d( + TAG, + ">>> dowork started for Fused download name ${fusedDownload?.name} $fusedDownloadString" + ) fusedDownload?.let { if (fusedDownload.status != Status.AWAITING) { - return@let + return Result.success() } setForeground( createForegroundInfo( @@ -75,15 +98,16 @@ class InstallAppWorker @AssistedInject constructor( ) ) startAppInstallationProcess(it) + mutex.lock() } - Log.d(TAG, "doWork: RESULT SUCCESS: ${fusedDownload?.name}") - return Result.success() } catch (e: Exception) { - Log.e(TAG, "doWork: Failed: ${e.stackTraceToString()}") + Log.e(TAG, ">>> doWork: Failed: ${e.stackTraceToString()}") fusedDownload?.let { fusedManagerRepository.installationIssue(it) } - return Result.failure() + } finally { + Log.d(TAG, ">>> doWork: RESULT SUCCESS: ${fusedDownload?.name}") + return Result.success() } } @@ -91,14 +115,39 @@ class InstallAppWorker @AssistedInject constructor( fusedDownload: FusedDownload ) { fusedManagerRepository.downloadApp(fusedDownload) - Log.d(TAG, "===> doWork: Download started ${fusedDownload.name} ${fusedDownload.status}") - if (fusedDownload.type == Type.NATIVE) { - isDownloading = true - tickerFlow(1.seconds) - .onEach { - checkDownloadProcess(fusedDownload) - }.launchIn(CoroutineScope(Dispatchers.IO)) - observeDownload(fusedDownload) + isDownloading = true + tickerFlow(3.seconds) + .onEach { + val download = databaseRepository.getDownloadById(fusedDownload.id) + if (download == null) { + isDownloading = false + unlockMutex() + } else { + handleFusedDownloadStatusCheckingException(download) + if (isAppDownloading(download)) { + checkDownloadProcess(download) + } + } + }.launchIn(CoroutineScope(Dispatchers.IO)) + Log.d( + TAG, + ">>> ===> doWork: Download started ${fusedDownload.name} ${fusedDownload.status}" + ) + } + + private fun isAppDownloading(download: FusedDownload): Boolean { + return download.type == Type.NATIVE && download.status != Status.INSTALLED && download.status != Status.INSTALLATION_ISSUE + } + + private suspend fun handleFusedDownloadStatusCheckingException( + download: FusedDownload + ) { + try { + handleFusedDownloadStatus(download) + } catch (e: Exception) { + Log.e(TAG, "observeDownload: ", e) + isDownloading = false + unlockMutex() } } @@ -111,50 +160,39 @@ class InstallAppWorker @AssistedInject constructor( } private suspend fun checkDownloadProcess(fusedDownload: FusedDownload) { - - downloadManager.query(downloadManagerQuery.setFilterById(*fusedDownload.downloadIdMap.keys.toLongArray())) - .use { cursor -> - if (cursor.moveToFirst()) { - val id = - cursor.getLong(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_ID)) - val status = - cursor.getInt(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS)) - val totalSizeBytes = - cursor.getLong(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_TOTAL_SIZE_BYTES)) - val bytesDownloadedSoFar = - cursor.getLong(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR)) - Log.d( - TAG, - "checkDownloadProcess: ${fusedDownload.name} $bytesDownloadedSoFar/$totalSizeBytes $status" - ) - if (status == DownloadManager.STATUS_FAILED) { - fusedManagerRepository.installationIssue(fusedDownload) + try { + downloadManager.query(downloadManagerQuery.setFilterById(*fusedDownload.downloadIdMap.keys.toLongArray())) + .use { cursor -> + if (cursor.moveToFirst()) { + val id = + cursor.getLong(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_ID)) + val status = + cursor.getInt(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS)) + val totalSizeBytes = + cursor.getLong(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_TOTAL_SIZE_BYTES)) + val bytesDownloadedSoFar = + cursor.getLong(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR)) + Log.d( + TAG, + "checkDownloadProcess: ${fusedDownload.name} $bytesDownloadedSoFar/$totalSizeBytes $status" + ) + if (status == DownloadManager.STATUS_FAILED) { + fusedManagerRepository.installationIssue(fusedDownload) + } } } - } - } - - private suspend fun observeDownload( - it: FusedDownload, - ) { - databaseRepository.getDownloadFlowById(it.id).takeWhile { isDownloading } - .collect { fusedDownload -> - if (fusedDownload == null) { - isDownloading = false - return@collect - } - Log.d( - TAG, - "doWork: flow collect ===> ${fusedDownload.name} ${fusedDownload.status}" - ) - handleFusedDownloadStatus(fusedDownload) - } + } catch (e: Exception) { + e.printStackTrace() + } } - private fun handleFusedDownloadStatus(fusedDownload: FusedDownload) { + private suspend fun handleFusedDownloadStatus(fusedDownload: FusedDownload) { when (fusedDownload.status) { Status.AWAITING, Status.DOWNLOADING -> { } + Status.DOWNLOADED -> { + fusedManagerRepository.updateDownloadStatus(fusedDownload, Status.INSTALLING) + } Status.INSTALLING -> { Log.d( TAG, @@ -165,11 +203,13 @@ class InstallAppWorker @AssistedInject constructor( isDownloading = false Log.d( TAG, - "===> doWork: Installed/Failed started ${fusedDownload.name} ${fusedDownload.status}" + "===> doWork: Installed/Failed: ${fusedDownload.name} ${fusedDownload.status}" ) + unlockMutex() } else -> { isDownloading = false + unlockMutex() Log.wtf( TAG, "===> ${fusedDownload.name} is in wrong state ${fusedDownload.status}" @@ -178,6 +218,12 @@ class InstallAppWorker @AssistedInject constructor( } } + private fun unlockMutex() { + if (mutex.isLocked) { + mutex.unlock() + } + } + private fun createForegroundInfo(progress: String): ForegroundInfo { val title = applicationContext.getString(R.string.app_name) val cancel = applicationContext.getString(R.string.cancel) @@ -193,7 +239,7 @@ class InstallAppWorker @AssistedInject constructor( val mChannel = NotificationChannel( "applounge_notification", title, - NotificationManager.IMPORTANCE_DEFAULT + NotificationManager.IMPORTANCE_LOW ) notificationManager.createNotificationChannel(mChannel) } diff --git a/app/src/main/java/foundation/e/apps/manager/workmanager/InstallWorkManager.kt b/app/src/main/java/foundation/e/apps/manager/workmanager/InstallWorkManager.kt index 809884ed0..08edd715f 100644 --- a/app/src/main/java/foundation/e/apps/manager/workmanager/InstallWorkManager.kt +++ b/app/src/main/java/foundation/e/apps/manager/workmanager/InstallWorkManager.kt @@ -19,7 +19,7 @@ object InstallWorkManager { Data.Builder() .putString(InstallAppWorker.INPUT_DATA_FUSED_DOWNLOAD, fusedDownload.id) .build() - ).addTag(fusedDownload.name) + ).addTag(fusedDownload.id) .build() ) } 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 63bea5d6a..a94fc7e2b 100644 --- a/app/src/main/java/foundation/e/apps/search/SearchFragment.kt +++ b/app/src/main/java/foundation/e/apps/search/SearchFragment.kt @@ -29,7 +29,6 @@ import android.widget.LinearLayout import androidx.appcompat.widget.SearchView import androidx.cursoradapter.widget.CursorAdapter import androidx.cursoradapter.widget.SimpleCursorAdapter -import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.fragment.app.viewModels import androidx.lifecycle.lifecycleScope @@ -37,6 +36,7 @@ import androidx.navigation.fragment.findNavController import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.aurora.gplayapi.SearchSuggestEntry +import com.aurora.gplayapi.data.models.AuthData import com.facebook.shimmer.ShimmerFrameLayout import dagger.hilt.android.AndroidEntryPoint import foundation.e.apps.AppInfoFetchViewModel @@ -49,16 +49,19 @@ import foundation.e.apps.api.fused.data.FusedApp import foundation.e.apps.application.subFrags.ApplicationDialogFragment import foundation.e.apps.applicationlist.model.ApplicationListRVAdapter import foundation.e.apps.databinding.FragmentSearchBinding +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.modules.PWAManagerModule +import foundation.e.apps.utils.parentFragment.TimeoutFragment import kotlinx.coroutines.launch import javax.inject.Inject @AndroidEntryPoint class SearchFragment : - Fragment(R.layout.fragment_search), + TimeoutFragment(R.layout.fragment_search), SearchView.OnQueryTextListener, SearchView.OnSuggestionListener, FusedAPIInterface { @@ -87,6 +90,12 @@ class SearchFragment : private var searchHintLayout: LinearLayout? = null private var noAppsFoundLayout: LinearLayout? = null + /* + * Store the string from onQueryTextSubmit() and access it from refreshData() + * Issue: https://gitlab.e.foundation/e/backlog/-/issues/5413 + */ + private var searchText = "" + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) _binding = FragmentSearchBinding.bind(view) @@ -152,44 +161,42 @@ class SearchFragment : layoutManager = LinearLayoutManager(view.context) } - appProgressViewModel.downloadProgress.observe(viewLifecycleOwner) { - val adapter = recyclerView?.adapter as ApplicationListRVAdapter - lifecycleScope.launch { - adapter.currentList.forEach { fusedApp -> - if (fusedApp.status == Status.DOWNLOADING) { - val progress = appProgressViewModel.calculateProgress(fusedApp, it) - val downloadProgress = - ((progress.second / progress.first.toDouble()) * 100).toInt() - val viewHolder = recyclerView?.findViewHolderForAdapterPosition( - adapter.currentList.indexOf(fusedApp) - ) - viewHolder?.let { - (viewHolder as ApplicationListRVAdapter.ViewHolder).binding.installButton.text = - "$downloadProgress%" - } - } - } + mainActivityViewModel.downloadList.observe(viewLifecycleOwner) { list -> + val searchList = + searchViewModel.searchResult.value?.data?.toMutableList() ?: emptyList() + searchList.let { + mainActivityViewModel.updateStatusOfFusedApps(searchList, list) } + + /* + * Done in one line, so that on Ctrl+click on searchResult, + * we can see that it is being updated here. + */ + searchViewModel.searchResult.apply { value?.setData(searchList) } } - mainActivityViewModel.downloadList.observe(viewLifecycleOwner) { list -> + /* + * Explanation of double observers in HomeFragment.kt + * Modified to check and search only if searchText in not blank, to prevent blank search. + */ - val searchList = searchViewModel.searchResult.value?.toMutableList() - searchList?.let { - mainActivityViewModel.updateStatusOfFusedApps(searchList, list) + mainActivityViewModel.internetConnection.observe(viewLifecycleOwner) { + if (searchText.isNotBlank()) { + refreshDataOrRefreshToken(mainActivityViewModel) + } + } + mainActivityViewModel.authData.observe(viewLifecycleOwner) { + if (searchText.isNotBlank()) { + refreshDataOrRefreshToken(mainActivityViewModel) } - searchViewModel.searchResult.value = searchList } searchViewModel.searchResult.observe(viewLifecycleOwner) { - - if (it.isNullOrEmpty()) { + if (it.data.isNullOrEmpty()) { noAppsFoundLayout?.visibility = View.VISIBLE } else { - - listAdapter?.setData(it) - shimmerLayout?.visibility = View.GONE - recyclerView?.visibility = View.VISIBLE + listAdapter?.setData(it.data!!) + stopLoadingUI() noAppsFoundLayout?.visibility = View.GONE } listAdapter?.registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() { @@ -209,12 +216,82 @@ class SearchFragment : } } }) + if (searchText.isNotBlank() && !it.isSuccess()) { + /* + * If blank check is not performed then timeout dialog keeps + * popping up whenever search tab is opened. + */ + onTimeout() + } + } + } + + override fun onTimeout() { + if (!isTimeoutDialogDisplayed()) { + stopLoadingUI() + displayTimeoutAlertDialog( + timeoutFragment = this, + activity = requireActivity(), + message = getString(R.string.timeout_desc_cleanapk), + positiveButtonText = getString(R.string.retry), + positiveButtonBlock = { + showLoadingUI() + resetTimeoutDialogLock() + mainActivityViewModel.retryFetchingTokenAfterTimeout() + }, + negativeButtonText = getString(android.R.string.ok), + negativeButtonBlock = {}, + allowCancel = true, + ) + } + } + + override fun refreshData(authData: AuthData) { + showLoadingUI() + searchViewModel.getSearchResults(searchText, authData, this) + } + + private fun showLoadingUI() { + binding.shimmerLayout.startShimmer() + binding.shimmerLayout.visibility = View.VISIBLE + binding.recyclerView.visibility = View.GONE + } + + private fun stopLoadingUI() { + binding.shimmerLayout.stopShimmer() + binding.shimmerLayout.visibility = View.GONE + binding.recyclerView.visibility = View.VISIBLE + } + + private fun updateProgressOfInstallingApps(downloadProgress: DownloadProgress) { + val adapter = recyclerView?.adapter as ApplicationListRVAdapter + lifecycleScope.launch { + adapter.currentList.forEach { fusedApp -> + if (fusedApp.status == Status.DOWNLOADING) { + val progress = + appProgressViewModel.calculateProgress(fusedApp, downloadProgress) + if (progress == -1) { + return@forEach + } + val viewHolder = recyclerView?.findViewHolderForAdapterPosition( + adapter.currentList.indexOf(fusedApp) + ) + viewHolder?.let { + (viewHolder as ApplicationListRVAdapter.ViewHolder).binding.installButton.text = + "$progress%" + } + } + } } } override fun onResume() { super.onResume() + resetTimeoutDialogLock() binding.shimmerLayout.startShimmer() + appProgressViewModel.downloadProgress.observe(viewLifecycleOwner) { + updateProgressOfInstallingApps(it) + } } override fun onPause() { @@ -223,7 +300,6 @@ class SearchFragment : } override fun onQueryTextSubmit(query: String?): Boolean { - query?.let { text -> hideKeyboard(activity as Activity) view?.requestFocus() @@ -231,7 +307,11 @@ class SearchFragment : shimmerLayout?.visibility = View.VISIBLE recyclerView?.visibility = View.GONE noAppsFoundLayout?.visibility = View.GONE - mainActivityViewModel.authData.value?.let { searchViewModel.getSearchResults(text, it, this) } + /* + * Set the search text and call for network result. + */ + searchText = text + refreshDataOrRefreshToken(mainActivityViewModel) } return false } @@ -250,9 +330,7 @@ class SearchFragment : } override fun onSuggestionClick(position: Int): Boolean { - searchViewModel.searchSuggest.value?.let { - searchView?.setQuery(it[position].suggestedQuery, true) } return true diff --git a/app/src/main/java/foundation/e/apps/search/SearchViewModel.kt b/app/src/main/java/foundation/e/apps/search/SearchViewModel.kt index 054528562..69e97ce2a 100644 --- a/app/src/main/java/foundation/e/apps/search/SearchViewModel.kt +++ b/app/src/main/java/foundation/e/apps/search/SearchViewModel.kt @@ -25,8 +25,10 @@ import androidx.lifecycle.viewModelScope import com.aurora.gplayapi.SearchSuggestEntry import com.aurora.gplayapi.data.models.AuthData 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.ResultStatus import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import javax.inject.Inject @@ -37,7 +39,7 @@ class SearchViewModel @Inject constructor( ) : ViewModel() { val searchSuggest: MutableLiveData?> = MutableLiveData() - val searchResult: MutableLiveData> = MutableLiveData() + val searchResult: MutableLiveData>> = MutableLiveData() fun getSearchSuggestions(query: String, authData: AuthData) { viewModelScope.launch(Dispatchers.IO) { 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 eb03b02d9..68016394a 100644 --- a/app/src/main/java/foundation/e/apps/updates/UpdatesFragment.kt +++ b/app/src/main/java/foundation/e/apps/updates/UpdatesFragment.kt @@ -21,19 +21,22 @@ package foundation.e.apps.updates 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 +import androidx.navigation.findNavController import androidx.navigation.fragment.findNavController import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView +import androidx.work.WorkManager +import com.aurora.gplayapi.data.models.AuthData import dagger.hilt.android.AndroidEntryPoint import foundation.e.apps.AppInfoFetchViewModel import foundation.e.apps.AppProgressViewModel import foundation.e.apps.MainActivityViewModel import foundation.e.apps.PrivacyInfoViewModel import foundation.e.apps.R +import foundation.e.apps.api.fused.FusedAPIImpl import foundation.e.apps.api.fused.FusedAPIInterface import foundation.e.apps.api.fused.data.FusedApp import foundation.e.apps.application.subFrags.ApplicationDialogFragment @@ -41,15 +44,19 @@ import foundation.e.apps.applicationlist.model.ApplicationListRVAdapter import foundation.e.apps.databinding.FragmentUpdatesBinding import foundation.e.apps.manager.download.data.DownloadProgress import foundation.e.apps.manager.pkg.PkgManagerModule +import foundation.e.apps.manager.workmanager.InstallWorkManager.INSTALL_WORK_NAME 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.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 @AndroidEntryPoint -class UpdatesFragment : Fragment(R.layout.fragment_updates), FusedAPIInterface { +class UpdatesFragment : TimeoutFragment(R.layout.fragment_updates), FusedAPIInterface { private var _binding: FragmentUpdatesBinding? = null private val binding get() = _binding!! @@ -74,15 +81,15 @@ class UpdatesFragment : Fragment(R.layout.fragment_updates), FusedAPIInterface { binding.button.isEnabled = false - mainActivityViewModel.internetConnection.observe(viewLifecycleOwner) { hasInternet -> - mainActivityViewModel.authData.observe(viewLifecycleOwner) { data -> - if (hasInternet) { - updatesViewModel.getUpdates(data) - binding.button.setOnClickListener { - UpdatesWorkManager.startUpdateAllWork(requireContext().applicationContext) - } - } - } + /* + * Explanation of double observers in HomeFragment.kt + */ + + mainActivityViewModel.internetConnection.observe(viewLifecycleOwner) { + refreshDataOrRefreshToken(mainActivityViewModel) + } + mainActivityViewModel.authData.observe(viewLifecycleOwner) { + refreshDataOrRefreshToken(mainActivityViewModel) } val recyclerView = binding.recyclerView @@ -121,35 +128,101 @@ class UpdatesFragment : Fragment(R.layout.fragment_updates), FusedAPIInterface { layoutManager = LinearLayoutManager(view.context) } - appProgressViewModel.downloadProgress.observe(viewLifecycleOwner) { - updateProgressOfDownloadingItems(recyclerView, it) - } - updatesViewModel.updatesList.observe(viewLifecycleOwner) { - listAdapter?.setData(it) + listAdapter?.setData(it.first) if (!isDownloadObserverAdded) { observeDownloadList() isDownloadObserverAdded = true } - binding.progressBar.visibility = View.GONE - recyclerView.visibility = View.VISIBLE - if (!it.isNullOrEmpty()) { + stopLoadingUI() + if (!it.first.isNullOrEmpty()) { binding.button.isEnabled = true binding.noUpdates.visibility = View.GONE } else { binding.noUpdates.visibility = View.VISIBLE binding.button.isEnabled = false } + + WorkManager.getInstance(requireContext()) + .getWorkInfosForUniqueWorkLiveData(INSTALL_WORK_NAME).observe(viewLifecycleOwner) { + lifecycleScope.launchWhenResumed { + binding.button.isEnabled = + !updatesViewModel.checkWorkInfoListHasAnyUpdatableWork(it) + } + } + + if (it.second != ResultStatus.OK) { + onTimeout() + } + } + } + + override fun onTimeout() { + if (!isTimeoutDialogDisplayed()) { + stopLoadingUI() + displayTimeoutAlertDialog( + timeoutFragment = this, + activity = requireActivity(), + message = + if (updatesViewModel.getApplicationCategoryPreference() == FusedAPIImpl.APP_TYPE_ANY) { + getString(R.string.timeout_desc_gplay) + } else { + getString(R.string.timeout_desc_cleanapk) + }, + positiveButtonText = getString(R.string.retry), + positiveButtonBlock = { + showLoadingUI() + resetTimeoutDialogLock() + mainActivityViewModel.retryFetchingTokenAfterTimeout() + }, + negativeButtonText = + if (updatesViewModel.getApplicationCategoryPreference() == FusedAPIImpl.APP_TYPE_ANY) { + getString(R.string.open_settings) + } else null, + negativeButtonBlock = { + openSettings() + }, + allowCancel = true, + ) } } + override fun refreshData(authData: AuthData) { + showLoadingUI() + updatesViewModel.getUpdates(authData) + binding.button.setOnClickListener { + UpdatesWorkManager.startUpdateAllWork(requireContext().applicationContext) + binding.button.isEnabled = false + } + } + + private fun showLoadingUI() { + binding.button.isEnabled = false + binding.noUpdates.visibility = View.GONE + binding.progressBar.visibility = View.VISIBLE + binding.recyclerView.visibility = View.INVISIBLE + } + + private fun stopLoadingUI() { + binding.progressBar.visibility = View.GONE + binding.recyclerView.visibility = View.VISIBLE + } + + override fun onResume() { + super.onResume() + appProgressViewModel.downloadProgress.observe(viewLifecycleOwner) { + updateProgressOfDownloadingItems(binding.recyclerView, it) + } + resetTimeoutDialogLock() + } + private fun observeDownloadList() { mainActivityViewModel.downloadList.observe(viewLifecycleOwner) { list -> - val appList = updatesViewModel.updatesList.value?.toMutableList() - appList?.let { + val appList = updatesViewModel.updatesList.value?.first?.toMutableList() ?: emptyList() + appList.let { mainActivityViewModel.updateStatusOfFusedApps(appList, list) } - updatesViewModel.updatesList.value = appList + updatesViewModel.updatesList.apply { value = Pair(appList, value?.second) } } } @@ -163,14 +236,15 @@ class UpdatesFragment : Fragment(R.layout.fragment_updates), FusedAPIInterface { if (fusedApp.status == Status.DOWNLOADING) { val progress = appProgressViewModel.calculateProgress(fusedApp, downloadProgress) - val downloadProgress = - ((progress.second / progress.first.toDouble()) * 100).toInt() + if (progress == -1) { + return@forEach + } val viewHolder = recyclerView.findViewHolderForAdapterPosition( adapter.currentList.indexOf(fusedApp) ) viewHolder?.let { (viewHolder as ApplicationListRVAdapter.ViewHolder).binding.installButton.text = - "$downloadProgress%" + "$progress%" } } } @@ -189,4 +263,9 @@ class UpdatesFragment : Fragment(R.layout.fragment_updates), FusedAPIInterface { override fun cancelDownload(app: FusedApp) { mainActivityViewModel.cancelDownload(app) } + + private fun openSettings() { + view?.findNavController() + ?.safeNavigate(R.id.updatesFragment, R.id.action_updatesFragment_to_SettingsFragment) + } } diff --git a/app/src/main/java/foundation/e/apps/updates/UpdatesViewModel.kt b/app/src/main/java/foundation/e/apps/updates/UpdatesViewModel.kt index a440d34ab..60d513d2a 100644 --- a/app/src/main/java/foundation/e/apps/updates/UpdatesViewModel.kt +++ b/app/src/main/java/foundation/e/apps/updates/UpdatesViewModel.kt @@ -18,29 +18,67 @@ package foundation.e.apps.updates +import android.util.Log import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import androidx.work.WorkInfo import com.aurora.gplayapi.data.models.AuthData import dagger.hilt.android.lifecycle.HiltViewModel +import foundation.e.apps.api.fused.FusedAPIRepository import foundation.e.apps.api.fused.data.FusedApp import foundation.e.apps.updates.manager.UpdatesManagerRepository +import foundation.e.apps.utils.enums.Status +import foundation.e.apps.utils.enums.ResultStatus import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel class UpdatesViewModel @Inject constructor( - private val updatesManagerRepository: UpdatesManagerRepository + private val updatesManagerRepository: UpdatesManagerRepository, + private val fusedAPIRepository: FusedAPIRepository ) : ViewModel() { - val updatesList: MutableLiveData> = MutableLiveData() + val updatesList: MutableLiveData, ResultStatus?>> = MutableLiveData() fun getUpdates(authData: AuthData) { viewModelScope.launch { + val updatesResult = updatesManagerRepository.getUpdates(authData) updatesList.postValue( - updatesManagerRepository.getUpdates(authData) - .filter { !(!it.isFree && authData.isAnonymous) } + Pair( + updatesResult.first.filter { !(!it.isFree && authData.isAnonymous) }, + updatesResult.second + ) ) } } + + suspend fun checkWorkInfoListHasAnyUpdatableWork(workInfoList: List): Boolean { + workInfoList.forEach { workInfo -> + if (listOf( + WorkInfo.State.ENQUEUED, + WorkInfo.State.RUNNING + ).contains(workInfo.state) && checkWorkIsForUpdateByTag(workInfo.tags.toList()) + ) { + return true + } + } + return false + } + + private fun checkWorkIsForUpdateByTag(tags: List): Boolean { + updatesList.value?.let { + it.first.find { fusedApp -> tags.contains(fusedApp._id) }?.let { foundApp -> + return listOf( + Status.INSTALLED, + Status.UPDATABLE + ).contains(fusedAPIRepository.getFusedAppInstallationStatus(foundApp)) + } + } + return false + } + + fun getApplicationCategoryPreference(): String { + return updatesManagerRepository.getApplicationCategoryPreference() + } } diff --git a/app/src/main/java/foundation/e/apps/updates/manager/UpdatesManagerImpl.kt b/app/src/main/java/foundation/e/apps/updates/manager/UpdatesManagerImpl.kt index 47443ad08..03e485126 100644 --- a/app/src/main/java/foundation/e/apps/updates/manager/UpdatesManagerImpl.kt +++ b/app/src/main/java/foundation/e/apps/updates/manager/UpdatesManagerImpl.kt @@ -23,6 +23,7 @@ import foundation.e.apps.api.fused.FusedAPIRepository import foundation.e.apps.api.fused.data.FusedApp import foundation.e.apps.manager.pkg.PkgManagerModule 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 @@ -33,35 +34,50 @@ class UpdatesManagerImpl @Inject constructor( private val TAG = UpdatesManagerImpl::class.java.simpleName // TODO: MAKE THIS LOGIC MORE SANE - suspend fun getUpdates(authData: AuthData): List { + suspend fun getUpdates(authData: AuthData): Pair, ResultStatus> { val pkgList = mutableListOf() val updateList = mutableListOf() + var status = ResultStatus.OK val userApplications = pkgManagerModule.getAllUserApps() userApplications.forEach { pkgList.add(it.packageName) } if (pkgList.isNotEmpty()) { // Get updates from CleanAPK - val cleanAPKList = fusedAPIRepository.getApplicationDetails( + val cleanAPKResult = fusedAPIRepository.getApplicationDetails( pkgList, authData, Origin.CLEANAPK ) - cleanAPKList.forEach { + cleanAPKResult.first.forEach { if (it.package_name in pkgList) pkgList.remove(it.package_name) if (it.status == Status.UPDATABLE) updateList.add(it) } + cleanAPKResult.second.let { + if (it != ResultStatus.OK) { + status = it + } + } // Check for remaining apps from GPlay - val gPlayList = fusedAPIRepository.getApplicationDetails( + val gPlayResult = fusedAPIRepository.getApplicationDetails( pkgList, authData, Origin.GPLAY ) - gPlayList.forEach { + gPlayResult.first.forEach { if (it.status == Status.UPDATABLE) updateList.add(it) } + gPlayResult.second.let { + if (it != ResultStatus.OK) { + status = it + } + } } - return updateList + return Pair(updateList, status) + } + + fun getApplicationCategoryPreference(): String { + return fusedAPIRepository.getApplicationCategoryPreference() } } diff --git a/app/src/main/java/foundation/e/apps/updates/manager/UpdatesManagerRepository.kt b/app/src/main/java/foundation/e/apps/updates/manager/UpdatesManagerRepository.kt index 51850286e..649c82261 100644 --- a/app/src/main/java/foundation/e/apps/updates/manager/UpdatesManagerRepository.kt +++ b/app/src/main/java/foundation/e/apps/updates/manager/UpdatesManagerRepository.kt @@ -20,13 +20,18 @@ package foundation.e.apps.updates.manager import com.aurora.gplayapi.data.models.AuthData import foundation.e.apps.api.fused.data.FusedApp +import foundation.e.apps.utils.enums.ResultStatus import javax.inject.Inject class UpdatesManagerRepository @Inject constructor( private val updatesManagerImpl: UpdatesManagerImpl ) { - suspend fun getUpdates(authData: AuthData): List { + suspend fun getUpdates(authData: AuthData): Pair, ResultStatus> { return updatesManagerImpl.getUpdates(authData) } + + fun getApplicationCategoryPreference(): String { + return updatesManagerImpl.getApplicationCategoryPreference() + } } diff --git a/app/src/main/java/foundation/e/apps/updates/manager/UpdatesWorkManager.kt b/app/src/main/java/foundation/e/apps/updates/manager/UpdatesWorkManager.kt index 577969c28..c70345a09 100644 --- a/app/src/main/java/foundation/e/apps/updates/manager/UpdatesWorkManager.kt +++ b/app/src/main/java/foundation/e/apps/updates/manager/UpdatesWorkManager.kt @@ -35,7 +35,7 @@ object UpdatesWorkManager { fun startUpdateAllWork(context: Context) { WorkManager.getInstance(context).enqueueUniqueWork( UPDATES_WORK_NAME, - ExistingWorkPolicy.KEEP, + ExistingWorkPolicy.REPLACE, buildOneTimeWorkRequest() ) } diff --git a/app/src/main/java/foundation/e/apps/updates/manager/UpdatesWorker.kt b/app/src/main/java/foundation/e/apps/updates/manager/UpdatesWorker.kt index bd2dc6fd3..3534c2309 100644 --- a/app/src/main/java/foundation/e/apps/updates/manager/UpdatesWorker.kt +++ b/app/src/main/java/foundation/e/apps/updates/manager/UpdatesWorker.kt @@ -14,7 +14,6 @@ import androidx.preference.PreferenceManager import androidx.work.CoroutineWorker import androidx.work.WorkerParameters import com.aurora.gplayapi.data.models.AuthData -import com.aurora.gplayapi.helpers.PurchaseHelper import com.google.gson.Gson import dagger.assisted.Assisted import dagger.assisted.AssistedInject @@ -60,7 +59,7 @@ class UpdatesWorker @AssistedInject constructor( loadSettings() val authData = getAuthData() val appsNeededToUpdate = updatesManagerRepository.getUpdates(authData) - .filter { !(!it.isFree && authData.isAnonymous) } + .first.filter { !(!it.isFree && authData.isAnonymous) } val isConnectedToUnmeteredNetwork = isConnectedToUnmeteredNetwork(applicationContext) /* * Show notification only if enabled. @@ -117,13 +116,8 @@ class UpdatesWorker @AssistedInject constructor( authData: AuthData ) { appsNeededToUpdate.forEach { fusedApp -> - if (!fusedApp.isFree) { - val purchaseHelper = PurchaseHelper(authData) - purchaseHelper.purchase( - fusedApp.package_name, - fusedApp.latest_version_code, - fusedApp.offer_type - ) + if (!fusedApp.isFree && authData.isAnonymous) { + return@forEach } val iconBase64 = getIconImageToBase64(fusedApp) @@ -144,7 +138,12 @@ class UpdatesWorker @AssistedInject constructor( fusedApp.originalSize ) - updateFusedDownloadWithAppDownloadLink(fusedApp, authData, fusedDownload) + try { + updateFusedDownloadWithAppDownloadLink(fusedApp, authData, fusedDownload) + } catch (e: Exception) { + e.printStackTrace() + return@forEach + } fusedManagerRepository.addDownload(fusedDownload) fusedManagerRepository.updateAwaiting(fusedDownload) @@ -153,6 +152,7 @@ class UpdatesWorker @AssistedInject constructor( "startUpdateProcess: Enqueued for update: ${fusedDownload.name} ${fusedDownload.id} ${fusedDownload.status}" ) InstallWorkManager.enqueueWork(fusedDownload) + Log.d(TAG, ">>> startUpdateProcess: ${fusedDownload.name}") } } 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 new file mode 100644 index 000000000..6fb9ceebe --- /dev/null +++ b/app/src/main/java/foundation/e/apps/utils/enums/ResultStatus.kt @@ -0,0 +1,7 @@ +package foundation.e.apps.utils.enums + +enum class ResultStatus { + OK, + TIMEOUT, + UNKNOWN, +} diff --git a/app/src/main/java/foundation/e/apps/utils/enums/Status.kt b/app/src/main/java/foundation/e/apps/utils/enums/Status.kt index 2b2eaad97..3fd339775 100644 --- a/app/src/main/java/foundation/e/apps/utils/enums/Status.kt +++ b/app/src/main/java/foundation/e/apps/utils/enums/Status.kt @@ -1,6 +1,6 @@ /* + * Copyright ECORP SAS 2022 * Apps Quickly and easily install Android apps onto your device! - * Copyright (C) 2021 E FOUNDATION * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -23,6 +23,7 @@ enum class Status { UPDATABLE, INSTALLING, DOWNLOADING, + DOWNLOADED, UNAVAILABLE, UNINSTALLING, QUEUED, 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 new file mode 100644 index 000000000..7900b2380 --- /dev/null +++ b/app/src/main/java/foundation/e/apps/utils/parentFragment/TimeoutFragment.kt @@ -0,0 +1,214 @@ +/* + * Copyright (C) 2022 ECORP + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package foundation.e.apps.utils.parentFragment + +import android.app.Activity +import android.view.KeyEvent +import androidx.annotation.LayoutRes +import androidx.appcompat.app.AlertDialog +import androidx.fragment.app.Fragment +import com.aurora.gplayapi.data.models.AuthData +import foundation.e.apps.MainActivityViewModel +import foundation.e.apps.R + +/* + * Parent class (extending fragment) for fragments which can display a timeout dialog + * for network calls exceeding timeout limit. + * Issue: https://gitlab.e.foundation/e/backlog/-/issues/5413 + */ +abstract class TimeoutFragment(@LayoutRes layoutId: Int) : Fragment(layoutId) { + + /* + * Alert dialog to show to user if App Lounge times out. + * + * Issue: https://gitlab.e.foundation/e/backlog/-/issues/5404 + */ + private var timeoutAlertDialog: AlertDialog? = null + + abstract fun onTimeout() + + /* + * Set this to true when timeout dialog is once shown. + * Set to false if user clicks "Retry". + * Use this to prevent repeatedly showing timeout dialog. + * + * Setting the value to true is automatically done from displayTimeoutAlertDialog(). + * To set it as false, call resetTimeoutDialogLock(). + * + * Timeout dialog maybe shown multiple times from MainActivity authData observer, + * MainActivityViewModel.downloadList observer, or simply from timing out while + * fetch the information for the fragment. + */ + private var timeoutDialogShownLock: Boolean = false + + /* + * Do call this in the "Retry" button block of timeout dialog. + * Also call this in onResume(), otherwise after screen off, the timeout dialog may not appear. + */ + fun resetTimeoutDialogLock() { + timeoutDialogShownLock = false + } + + /* + * Recommended to put code to refresh data inside this block. + * But call refreshDataOrRefreshToken() to execute the refresh. + */ + abstract fun refreshData(authData: AuthData) + + /* + * Checks if network connectivity is present. + * -- If yes, then checks if valid authData is present. + * ---- If yes, then dismiss timeout dialog (if showing) and call refreshData() + * ---- If no, then request new token data. + */ + fun refreshDataOrRefreshToken(mainActivityViewModel: MainActivityViewModel) { + if (mainActivityViewModel.internetConnection.value == true) { + mainActivityViewModel.authData.value?.let { authData -> + dismissTimeoutDialog() + refreshData(authData) + } ?: run { + mainActivityViewModel.retryFetchingTokenAfterTimeout() + } + } + } + + /** + * Display timeout alert dialog. + * + * @param activity Activity class. Basically the MainActivity. + * @param message Alert dialog body. + * @param positiveButtonText Positive button text. Example "Retry" + * @param positiveButtonBlock Code block when [positiveButtonText] is pressed. + * @param negativeButtonText Negative button text. Example "Retry" + * @param negativeButtonBlock Code block when [negativeButtonText] is pressed. + * @param positiveButtonText Positive button text. Example "Retry" + * @param positiveButtonBlock Code block when [positiveButtonText] is pressed. + * + * Issue: https://gitlab.e.foundation/e/backlog/-/issues/5404 + * Issue: https://gitlab.e.foundation/e/backlog/-/issues/5413 + */ + fun displayTimeoutAlertDialog( + timeoutFragment: TimeoutFragment, + activity: Activity, + message: String, + positiveButtonText: String? = null, + positiveButtonBlock: (() -> Unit)? = null, + negativeButtonText: String? = null, + negativeButtonBlock: (() -> Unit)? = null, + neutralButtonText: String? = null, + neutralButtonBlock: (() -> Unit)? = null, + allowCancel: Boolean = true, + ) { + + /* + * If timeout dialog is already shown, don't proceed. + */ + if (timeoutFragment.timeoutDialogShownLock) { + return + } + + val timeoutAlertDialogBuilder = AlertDialog.Builder(activity).apply { + + /* + * Set title. + */ + setTitle(R.string.timeout_title) + + if (!allowCancel) { + /* + * Prevent dismissing the dialog from pressing outside as it will only + * show a blank screen below the dialog. + */ + setCancelable(false) + /* + * If user presses back button to close the dialog without selecting anything, + * close App Lounge. + */ + setOnKeyListener { dialog, keyCode, _ -> + if (keyCode == KeyEvent.KEYCODE_BACK) { + dialog.dismiss() + activity.finish() + } + true + } + } else { + setCancelable(true) + } + + /* + * Set message + */ + setMessage(message) + + /* + * Set buttons. + */ + positiveButtonText?.let { + setPositiveButton(it) { _, _ -> + positiveButtonBlock?.invoke() + } + } + negativeButtonText?.let { + setNegativeButton(it) { _, _ -> + negativeButtonBlock?.invoke() + } + } + neutralButtonText?.let { + setNeutralButton(it) { _, _ -> + neutralButtonBlock?.invoke() + } + } + } + + /* + * Dismiss alert dialog if already being shown + */ + try { + timeoutAlertDialog?.dismiss() + } catch (_: Exception) {} + + timeoutAlertDialog = timeoutAlertDialogBuilder.create() + timeoutAlertDialog?.show() + + /* + * Mark timeout dialog is already shown. + */ + timeoutFragment.timeoutDialogShownLock = true + } + + /** + * Returns true if [timeoutAlertDialog] is displaying. + * Returs false if it is not initialised. + */ + fun isTimeoutDialogDisplayed(): Boolean { + return timeoutAlertDialog?.isShowing == true + } + + /** + * Dismisses the [timeoutAlertDialog] if it is being displayed. + * Does nothing if it is not being displayed. + * Caller need not check if the dialog is being displayed. + */ + fun dismissTimeoutDialog() { + if (isTimeoutDialogDisplayed()) { + try { + timeoutAlertDialog?.dismiss() + } catch (_: Exception) {} + } + } +} diff --git a/app/src/main/res/layout/application_list_item.xml b/app/src/main/res/layout/application_list_item.xml index a9d60a107..31312cde5 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"> + + + tools:text="Google Chrome - Fast and Secured" /> diff --git a/app/src/main/res/navigation/navigation_resource.xml b/app/src/main/res/navigation/navigation_resource.xml index 5fd2fa9ca..2bacd70dc 100644 --- a/app/src/main/res/navigation/navigation_resource.xml +++ b/app/src/main/res/navigation/navigation_resource.xml @@ -75,6 +75,9 @@ + Aktualisierungen App-Aktualisierungen werden nicht automatisch installiert Auf ein unlimitiertes Netzwerk warten - Keine runtime android Berechtigung gefunden! + Keine „runtime android“-Berechtigung gefunden! Keine Tracker/Verfolger gefunden! Keine Verbindung möglich. Bitte überprüfe die Internetverbindung und versuche es erneut Start @@ -107,7 +107,7 @@ Bezahl-Apps können nicht im anonymen Modus installiert werden. Bitte melde dich mit deinem Google-Konto an. Es ist keine Tracker-Information für diese App verfügbar. Die %s App wird zurzeit nicht unterstützt. Ein Grund könnte sein, dass die App noch nicht sehr verbreitet, oder ein anderer Fehler aufgetreten ist. - Nicht unterstütze App! + Diese App wird nicht unterstützt! Einstellungen öffnen Gewisse Netzwerkfehler verhindern das Holen der Apps. \n @@ -116,4 +116,12 @@ Zeit abgelaufen beim Holen von Apps! Deine App wurde nicht gefunden. Wird installiert + Die Installation zu erzwingen, erlaubt es dir, die App herunterzuladen und zu installieren, garantiert aber nicht, dass sie funktioniert.

Zu versuchen, nicht unterstützte Apps zu installieren, kann Abstürze verursachen oder das System verlangsamen.

Wir arbeiten daran, die Kompatibilität mit dieser Anwendung in naher Zukunft zu verbessern.
+ Diese App könnte nicht korrekt funktionieren! + Trotzdem installieren + Fehler beim Kauf! + Deine App wird automatisch auf dieses Gerät heruntergeladen werden + Kauf abgeschlossen! + Mehr anzeigen + Etwas ist schiefgegangen! \ No newline at end of file -- GitLab From 0e61a0ce7889610bbb0de48f482a9e8e560cb65e Mon Sep 17 00:00:00 2001 From: SayantanRC Date: Mon, 13 Jun 2022 19:14:27 +0530 Subject: [PATCH 10/12] issue_5171 [WIP]: Send Pair(list, Boolean) denoting more apps to load from GPlay --- .../java/foundation/e/apps/api/gplay/GPlayAPIImpl.kt | 10 +++++++--- .../foundation/e/apps/api/gplay/GPlayAPIRepository.kt | 2 +- 2 files changed, 8 insertions(+), 4 deletions(-) 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 f2e84858c..904b8cc49 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 @@ -100,7 +100,11 @@ class GPlayAPIImpl @Inject constructor( return searchData.filter { it.suggestedQuery.isNotBlank() } } - fun getSearchResults(query: String, authData: AuthData): LiveData> { + /** + * Sends livedata of list of apps being loaded from search and a boolean + * signifying if more data is to be loaded. + */ + fun getSearchResults(query: String, authData: AuthData): LiveData, Boolean>> { /* * Send livedata to improve UI performance, so we don't have to wait for loading all results. * Issue: https://gitlab.e.foundation/e/backlog/-/issues/5171 @@ -114,7 +118,7 @@ class GPlayAPIImpl @Inject constructor( val searchHelper = SearchHelper(authData).using(gPlayHttpClient) val searchBundle = searchHelper.searchResults(query) - emit(searchBundle.appList) + emit(Pair(searchBundle.appList, true)) var nextSubBundleSet: MutableSet do { @@ -125,7 +129,7 @@ class GPlayAPIImpl @Inject constructor( subBundles.clear() subBundles.addAll(newSearchBundle.subBundles) appList.addAll(newSearchBundle.appList) - emit(searchBundle.appList) + emit(Pair(searchBundle.appList, nextSubBundleSet.isNotEmpty())) } } } while (nextSubBundleSet.isNotEmpty()) 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 d9713f321..b6b098cbb 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 @@ -49,7 +49,7 @@ class GPlayAPIRepository @Inject constructor( return gPlayAPIImpl.getSearchSuggestions(query, authData) } - fun getSearchResults(query: String, authData: AuthData): LiveData> { + fun getSearchResults(query: String, authData: AuthData): LiveData, Boolean>> { return gPlayAPIImpl.getSearchResults(query, authData) } -- GitLab From d96d05df21b9f63e09deb85648750ea31e97ceec Mon Sep 17 00:00:00 2001 From: SayantanRC Date: Mon, 13 Jun 2022 19:49:09 +0530 Subject: [PATCH 11/12] issue_5171 [WIP]: send boolean status of search apps from FusedAPIImpl --- .../e/apps/api/fused/FusedAPIImpl.kt | 60 ++++++++++++++----- .../e/apps/api/fused/FusedAPIRepository.kt | 2 +- 2 files changed, 47 insertions(+), 15 deletions(-) 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 842fd1119..e7a813bf6 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 @@ -208,10 +208,14 @@ class FusedAPIImpl @Inject constructor( * Fetches search results from cleanAPK and GPlay servers and returns them * @param query Query * @param authData [AuthData] - * @return A livedata list of non-nullable [FusedApp]. + * @return A livedata Pair of list of non-nullable [FusedApp] and + * a Boolean signifying if more search results are being loaded. * Observe this livedata to display new apps as they are fetched from the network. */ - fun getSearchResults(query: String, authData: AuthData): LiveData>> { + fun getSearchResults( + query: String, + authData: AuthData + ): LiveData, Boolean>>> { /* * Returning livedata to improve performance, so that we do not have to wait forever * for all results to be fetched from network before showing them. @@ -247,9 +251,10 @@ class FusedAPIImpl @Inject constructor( /* * If there was a timeout, return it and don't try to fetch anything else. + * Also send true in the pair to signal more results being loaded. */ if (status != ResultStatus.OK) { - emit(ResultSupreme.create(status, packageSpecificResults)) + emit(ResultSupreme.create(status, Pair(packageSpecificResults, true))) return@liveData } @@ -278,8 +283,14 @@ class FusedAPIImpl @Inject constructor( * If cleanapk results are empty, dont emit emit data as it may * briefly show "No apps found..." * If status is timeout, then do emit the value. + * Send true in the pair to signal more results (i.e from GPlay) being loaded. */ - emit(ResultSupreme.create(status, filterWithKeywordSearch(cleanApkResults))) + emit( + ResultSupreme.create( + status, + Pair(filterWithKeywordSearch(cleanApkResults), true) + ) + ) } emitSource( getGplayAndCleanapkCombinedResults(query, authData, cleanApkResults).map { @@ -288,7 +299,7 @@ class FusedAPIImpl @Inject constructor( * If there had to be any timeout, it would already have happened * while fetching package specific results. */ - ResultSupreme.Success(filterWithKeywordSearch(it)) + ResultSupreme.Success(Pair(filterWithKeywordSearch(it.first), it.second)) } ) } @@ -296,7 +307,16 @@ class FusedAPIImpl @Inject constructor( val status = runCodeBlockWithTimeout({ cleanApkResults.addAll(getCleanAPKSearchResults(query)) }) - emit(ResultSupreme.create(status, filterWithKeywordSearch(cleanApkResults))) + /* + * Send false in pair to signal no more results to load, as only cleanapk + * results are fetched, we don't have to wait for GPlay results. + */ + emit( + ResultSupreme.create( + status, + Pair(filterWithKeywordSearch(cleanApkResults), false) + ) + ) } APP_TYPE_PWA -> { val status = runCodeBlockWithTimeout({ @@ -308,7 +328,11 @@ class FusedAPIImpl @Inject constructor( ) ) }) - emit(ResultSupreme.create(status, cleanApkResults)) + /* + * Send false in pair to signal no more results to load, as only cleanapk + * results are fetched for PWAs. + */ + emit(ResultSupreme.create(status, Pair(cleanApkResults, false))) } } } @@ -939,19 +963,27 @@ class FusedAPIImpl @Inject constructor( query: String, authData: AuthData, cleanApkResults: List - ): LiveData> { + ): LiveData, Boolean>> { val localList = ArrayList(cleanApkResults) - return getGplaySearchResults(query, authData).map { list -> - localList.apply { - addAll(list) - }.distinctBy { it.package_name } + return getGplaySearchResults(query, authData).map { pair -> + Pair( + localList.apply { addAll(pair.first) }.distinctBy { it.package_name }, + pair.second + ) } } - private fun getGplaySearchResults(query: String, authData: AuthData): LiveData> { + private fun getGplaySearchResults( + query: String, + authData: AuthData + ): LiveData, Boolean>> { val searchResults = gPlayAPIRepository.getSearchResults(query, authData) return searchResults.map { - it.map { app -> app.transformToFusedApp() } + Pair( + it.first.map { app -> app.transformToFusedApp() }, + it.second + ) + } } 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 fbecbc31d..5a6717d5d 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 @@ -112,7 +112,7 @@ class FusedAPIRepository @Inject constructor( return fusedAPIImpl.fetchAuthData(email, aasToken) } - fun getSearchResults(query: String, authData: AuthData): LiveData>> { + fun getSearchResults(query: String, authData: AuthData): LiveData, Boolean>>> { return fusedAPIImpl.getSearchResults(query, authData) } -- GitLab From a2391203a54e6e068723316ec231739858435295 Mon Sep 17 00:00:00 2001 From: SayantanRC Date: Mon, 13 Jun 2022 19:52:54 +0530 Subject: [PATCH 12/12] issue_5171: implement showing boolean loading progressbar for search results --- .../java/foundation/e/apps/search/SearchFragment.kt | 11 +++++++---- .../java/foundation/e/apps/search/SearchViewModel.kt | 3 +-- app/src/main/res/layout/fragment_search.xml | 11 ++++++++++- 3 files changed, 18 insertions(+), 7 deletions(-) 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 a94fc7e2b..2c3c305e3 100644 --- a/app/src/main/java/foundation/e/apps/search/SearchFragment.kt +++ b/app/src/main/java/foundation/e/apps/search/SearchFragment.kt @@ -27,6 +27,7 @@ import android.view.inputmethod.InputMethodManager import android.widget.ImageView import android.widget.LinearLayout import androidx.appcompat.widget.SearchView +import androidx.core.view.isVisible import androidx.cursoradapter.widget.CursorAdapter import androidx.cursoradapter.widget.SimpleCursorAdapter import androidx.fragment.app.activityViewModels @@ -163,7 +164,7 @@ class SearchFragment : mainActivityViewModel.downloadList.observe(viewLifecycleOwner) { list -> val searchList = - searchViewModel.searchResult.value?.data?.toMutableList() ?: emptyList() + searchViewModel.searchResult.value?.data?.first?.toMutableList() ?: emptyList() searchList.let { mainActivityViewModel.updateStatusOfFusedApps(searchList, list) } @@ -172,7 +173,7 @@ class SearchFragment : * Done in one line, so that on Ctrl+click on searchResult, * we can see that it is being updated here. */ - searchViewModel.searchResult.apply { value?.setData(searchList) } + searchViewModel.searchResult.apply { value?.setData(Pair(searchList, value?.data?.second ?: false)) } } /* @@ -192,10 +193,11 @@ class SearchFragment : } searchViewModel.searchResult.observe(viewLifecycleOwner) { - if (it.data.isNullOrEmpty()) { + if (it.data?.first.isNullOrEmpty()) { noAppsFoundLayout?.visibility = View.VISIBLE } else { - listAdapter?.setData(it.data!!) + listAdapter?.setData(it.data!!.first) + binding.loadingProgressBar.isVisible = it.data!!.second stopLoadingUI() noAppsFoundLayout?.visibility = View.GONE } @@ -228,6 +230,7 @@ class SearchFragment : override fun onTimeout() { if (!isTimeoutDialogDisplayed()) { + binding.loadingProgressBar.isVisible = false stopLoadingUI() displayTimeoutAlertDialog( timeoutFragment = this, diff --git a/app/src/main/java/foundation/e/apps/search/SearchViewModel.kt b/app/src/main/java/foundation/e/apps/search/SearchViewModel.kt index 69e97ce2a..3ae636d47 100644 --- a/app/src/main/java/foundation/e/apps/search/SearchViewModel.kt +++ b/app/src/main/java/foundation/e/apps/search/SearchViewModel.kt @@ -28,7 +28,6 @@ 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.ResultStatus import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import javax.inject.Inject @@ -39,7 +38,7 @@ class SearchViewModel @Inject constructor( ) : ViewModel() { val searchSuggest: MutableLiveData?> = MutableLiveData() - val searchResult: MutableLiveData>> = MutableLiveData() + val searchResult: MutableLiveData, Boolean>>> = MutableLiveData() fun getSearchSuggestions(query: String, authData: AuthData) { viewModelScope.launch(Dispatchers.IO) { diff --git a/app/src/main/res/layout/fragment_search.xml b/app/src/main/res/layout/fragment_search.xml index 30699e080..a4e3fbf23 100644 --- a/app/src/main/res/layout/fragment_search.xml +++ b/app/src/main/res/layout/fragment_search.xml @@ -50,7 +50,8 @@ + \ No newline at end of file -- GitLab