diff --git a/app/src/main/java/foundation/e/apps/MainActivity.kt b/app/src/main/java/foundation/e/apps/MainActivity.kt index ab787c6ef9e5e698c55f571911638bc487edb9b9..733b681073fbf578092cd3a4606fb626dca64f58 100644 --- a/app/src/main/java/foundation/e/apps/MainActivity.kt +++ b/app/src/main/java/foundation/e/apps/MainActivity.kt @@ -45,6 +45,7 @@ import foundation.e.apps.setup.signin.SignInViewModel import foundation.e.apps.updates.UpdatesNotifier import foundation.e.apps.utils.enums.Status import foundation.e.apps.utils.enums.User +import foundation.e.apps.utils.parentFragment.TimeoutFragment import foundation.e.apps.utils.modules.CommonUtilsModule import kotlinx.coroutines.launch import java.io.File @@ -140,6 +141,12 @@ 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 82d0ebd57ec45d98234f06603327b9702e7d5319..1150e9e824e3fe71f4823a2b2e3aa2f18d26fd97 100644 --- a/app/src/main/java/foundation/e/apps/MainActivityViewModel.kt +++ b/app/src/main/java/foundation/e/apps/MainActivityViewModel.kt @@ -20,13 +20,11 @@ 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 @@ -50,7 +48,6 @@ 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 +92,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 +109,9 @@ class MainActivityViewModel @Inject constructor( } fun setFirstTokenFetchTime() { - firstAuthDataFetchTime = SystemClock.uptimeMillis() + if (firstAuthDataFetchTime == 0L) { + firstAuthDataFetchTime = SystemClock.uptimeMillis() + } } fun isTimeEligibleForTokenRefresh(): Boolean { @@ -229,6 +126,7 @@ class MainActivityViewModel @Inject constructor( * Issue: https://gitlab.e.foundation/e/backlog/-/issues/5404 */ fun retryFetchingTokenAfterTimeout() { + firstAuthDataFetchTime = 0 setFirstTokenFetchTime() authValidity.postValue(false) } @@ -249,7 +147,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) + } } } } 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 0000000000000000000000000000000000000000..9a57011d25fdc3638f0081dcad095ff1e14aa573 --- /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) + }*/ + } +} \ No newline at end of file diff --git a/app/src/main/java/foundation/e/apps/api/fused/FusedAPIImpl.kt b/app/src/main/java/foundation/e/apps/api/fused/FusedAPIImpl.kt index 8130bf9ae6fc480db5c45f3106073c803a498c07..aac88fa671f1c58045a75cdaa3d332c3e1694884 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 @@ -47,6 +47,7 @@ import foundation.e.apps.utils.enums.AppTag import foundation.e.apps.utils.enums.Origin import foundation.e.apps.utils.enums.Status import foundation.e.apps.utils.enums.Type +import foundation.e.apps.utils.enums.ResultStatus import foundation.e.apps.utils.modules.CommonUtilsModule.timeoutDurationInMillis import foundation.e.apps.utils.modules.PWAManagerModule import foundation.e.apps.utils.modules.PreferenceManagerModule @@ -83,20 +84,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) } /** @@ -111,6 +108,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. @@ -120,8 +121,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. @@ -152,24 +154,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) } /** @@ -178,35 +204,37 @@ class FusedAPIImpl @Inject constructor( * @param authData [AuthData] * @return A list of nullable [FusedApp] */ - suspend fun getSearchResults(query: String, authData: AuthData): List { + suspend fun getSearchResults(query: String, authData: AuthData): Pair, ResultStatus> { 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 + val status = runCodeBlockWithTimeout({ + 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 + ) ) - ) + } } - } - return fusedResponse.distinctBy { it.package_name } + }) + return Pair(fusedResponse.distinctBy { it.package_name }, status) } suspend fun getSearchSuggestions(query: String, authData: AuthData): List { return gPlayAPIRepository.getSearchSuggestions(query, authData) } - suspend fun fetchAuthData(): Unit? { + suspend fun fetchAuthData(): Boolean { return gPlayAPIRepository.fetchAuthData() } @@ -267,28 +295,40 @@ class FusedAPIImpl @Inject constructor( } } - suspend fun getPWAApps(category: String): List? { - val response = getPWAAppsResponse(category) - response?.apps?.forEach { - it.updateStatus() - it.updateType() - } - return response?.apps + suspend fun getPWAApps(category: String): Pair, ResultStatus> { + var list = mutableListOf() + val status = runCodeBlockWithTimeout({ + val response = getPWAAppsResponse(category) + response?.apps?.forEach { + it.updateStatus() + it.updateType() + list.add(it) + } + }) + return Pair(list, status) } - suspend fun getOpenSourceApps(category: String): List? { - val response = getOpenSourceAppsResponse(category) - response?.apps?.forEach { - it.updateStatus() - it.updateType() - } - return response?.apps + suspend fun getOpenSourceApps(category: String): Pair, ResultStatus> { + val list = mutableListOf() + val status = runCodeBlockWithTimeout({ + val response = getOpenSourceAppsResponse(category) + response?.apps?.forEach { + it.updateStatus() + it.updateType() + list.add(it) + } + }) + return Pair(list, status) } - suspend fun getPlayStoreApps(browseUrl: String, authData: AuthData): List { - return gPlayAPIRepository.listApps(browseUrl, authData).map { app -> - app.transformToFusedApp() - } + suspend fun getPlayStoreApps(browseUrl: String, authData: AuthData): Pair, ResultStatus> { + var list = mutableListOf() + val status = runCodeBlockWithTimeout({ + list.addAll(gPlayAPIRepository.listApps(browseUrl, authData).map { app -> + app.transformToFusedApp() + }) + }) + return Pair(list, status) } suspend fun getPlayStoreAppCategoryUrls(browseUrl: String, authData: AuthData): List { @@ -298,32 +338,96 @@ class FusedAPIImpl @Inject constructor( suspend fun getAppsAndNextClusterUrl( browseUrl: String, authData: AuthData - ): Pair, String> { - return gPlayAPIRepository.getAppsAndNextClusterUrl(browseUrl, authData).let { - Pair(it.first.map { app -> app.transformToFusedApp() }, it.second) - } + ): Triple, String, ResultStatus> { + val appsList = mutableListOf() + var nextUrl = "" + val status = runCodeBlockWithTimeout({ + val gPlayResult = gPlayAPIRepository.getAppsAndNextClusterUrl(browseUrl, authData) + appsList.addAll(gPlayResult.first.map { app -> app.transformToFusedApp() }) + nextUrl = gPlayResult.second + }) + + return Triple(appsList, nextUrl, status) } 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. @@ -341,15 +445,9 @@ class FusedAPIImpl @Inject constructor( app.transformToFusedApp() } } - } - response.forEach { - if (it.package_name.isNotBlank()) { - it.updateStatus() - it.updateType() - list.add(it) - } - } - return list + }) + + return Pair(fusedAppList, status) } suspend fun getApplicationDetails( @@ -357,18 +455,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) } /* @@ -379,18 +483,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 { @@ -409,35 +514,116 @@ 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( 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 745dd0773b25bb9ab2ef2ee11be3896266e83ae7..f92123cc05b22f02e9e8a5b7ebbf0b45e4cef713 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 @@ -25,6 +25,7 @@ import foundation.e.apps.api.fused.data.FusedApp import foundation.e.apps.api.fused.data.FusedCategory import foundation.e.apps.api.fused.data.FusedHome import foundation.e.apps.manager.database.fusedDownload.FusedDownload +import foundation.e.apps.utils.enums.ResultStatus import foundation.e.apps.utils.enums.Origin import foundation.e.apps.utils.enums.Status import javax.inject.Inject @@ -34,7 +35,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) } @@ -42,6 +43,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) } @@ -50,7 +55,7 @@ class FusedAPIRepository @Inject constructor( packageNameList: List, authData: AuthData, origin: Origin - ): List { + ): Pair, ResultStatus> { return fusedAPIImpl.getApplicationDetails(packageNameList, authData, origin) } @@ -59,7 +64,7 @@ class FusedAPIRepository @Inject constructor( packageName: String, authData: AuthData, origin: Origin - ): FusedApp { + ): Pair { return fusedAPIImpl.getApplicationDetails(id, packageName, authData, origin) } @@ -75,7 +80,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) } @@ -83,7 +88,7 @@ class FusedAPIRepository @Inject constructor( return fusedAPIImpl.getSearchSuggestions(query, authData) } - suspend fun fetchAuthData(): Unit? { + suspend fun fetchAuthData(): Boolean { return fusedAPIImpl.fetchAuthData() } @@ -91,7 +96,7 @@ class FusedAPIRepository @Inject constructor( return fusedAPIImpl.fetchAuthData(email, aasToken) } - suspend fun getSearchResults(query: String, authData: AuthData): List { + suspend fun getSearchResults(query: String, authData: AuthData): Pair, ResultStatus> { return fusedAPIImpl.getSearchResults(query, authData) } @@ -103,7 +108,7 @@ class FusedAPIRepository @Inject constructor( return fusedAPIImpl.getPlayStoreAppCategoryUrls(browseUrl, authData) } - suspend fun getAppsAndNextClusterUrl(browseUrl: String, authData: AuthData): Pair, String> { + suspend fun getAppsAndNextClusterUrl(browseUrl: String, authData: AuthData): Triple, String, ResultStatus> { return fusedAPIImpl.getAppsAndNextClusterUrl(browseUrl, authData) } @@ -112,10 +117,10 @@ class FusedAPIRepository @Inject constructor( browseUrl: String, authData: AuthData, source: String - ): List { + ): Pair, ResultStatus> { 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/gplay/GPlayAPIImpl.kt b/app/src/main/java/foundation/e/apps/api/gplay/GPlayAPIImpl.kt index f367cdaba8c0bc87a50010fad952193d86458c07..e4b8fc65e0abb564052ee751b61e3b7bb6d8b883 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 @@ -51,12 +51,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 } } 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 56243a6a50c334d513c0f345cd3f76080b971492..18f37ba54dcbe0dbc41ae2373e5d3ceea2cad485 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 @@ -30,7 +30,7 @@ class GPlayAPIRepository @Inject constructor( private val gPlayAPIImpl: GPlayAPIImpl ) { - suspend fun fetchAuthData(): Unit? { + suspend fun fetchAuthData(): Boolean { return gPlayAPIImpl.fetchAuthData() } 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 14aef8e7242945c603d82b71feb484fc63b0627d..d7cb13e5e392e374b34995fbaeaab1a54844a6bd 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,8 +57,10 @@ 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.parentFragment.TimeoutFragment import foundation.e.apps.utils.modules.CommonUtilsModule.LIST_OF_NULL import foundation.e.apps.utils.modules.PWAManagerModule import kotlinx.coroutines.Dispatchers @@ -66,7 +68,7 @@ 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 } @@ -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) @@ -269,12 +287,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( @@ -558,13 +608,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 ea96457cbf0e7ee1ca868e24f139090e6ae032dd..f64c956185cde2fd310ba8d6f342cbe297892460 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 e0b206aa5e3605f427b93123abd4c4139adc1e77..16513628e82d9257413b75c395fae248df23408e 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 @@ -43,14 +43,16 @@ import foundation.e.apps.applicationlist.model.ApplicationListRVAdapter import foundation.e.apps.databinding.FragmentApplicationListBinding import foundation.e.apps.manager.download.data.DownloadProgress import foundation.e.apps.manager.pkg.PkgManagerModule +import foundation.e.apps.utils.enums.ResultStatus import foundation.e.apps.utils.enums.Status import foundation.e.apps.utils.enums.User +import foundation.e.apps.utils.parentFragment.TimeoutFragment import foundation.e.apps.utils.modules.PWAManagerModule import 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 +90,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?.first?.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 = Pair(appList, value?.second) } } } @@ -148,44 +155,91 @@ class ApplicationListFragment : Fragment(R.layout.fragment_application_list), Fu } viewModel.appListLiveData.observe(viewLifecycleOwner) { - listAdapter?.setData(it) + listAdapter?.setData(it.first) if (!isDownloadObserverAdded) { observeDownloadList() isDownloadObserverAdded = true } - binding.shimmerLayout.visibility = View.GONE - recyclerView.visibility = View.VISIBLE + stopLoadingUI() + if (it.second != ResultStatus.OK) { + onTimeout() + } } - 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, + ) + } + } - 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) - } - } - }) + override fun refreshData(authData: AuthData) { + showLoadingUI() + + /* + * 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.getPlayStoreAppsOnScroll(args.browseUrl, 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 + } + private fun updateProgressOfDownloadingItems( recyclerView: RecyclerView, it: DownloadProgress 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 cd710a7317b7dbe1298980645515b7e903bc167d..72a518e18936b5cfe9e6bee3d94d315004b1bd89 100644 --- a/app/src/main/java/foundation/e/apps/applicationlist/ApplicationListViewModel.kt +++ b/app/src/main/java/foundation/e/apps/applicationlist/ApplicationListViewModel.kt @@ -26,6 +26,7 @@ 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.utils.enums.Origin +import foundation.e.apps.utils.enums.ResultStatus import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import javax.inject.Inject @@ -35,7 +36,7 @@ class ApplicationListViewModel @Inject constructor( private val fusedAPIRepository: FusedAPIRepository ) : ViewModel() { - val appListLiveData: MutableLiveData> = MutableLiveData() + val appListLiveData: MutableLiveData, ResultStatus?>> = MutableLiveData() private var lastBrowseUrl = String() @@ -66,7 +67,7 @@ class ApplicationListViewModel @Inject constructor( * Add existing apps now and add additional apps later. */ val newList = mutableListOf().apply { - appListLiveData.value?.let { addAll(it) } + appListLiveData.value?.first?.let { addAll(it) } } /** @@ -124,7 +125,7 @@ class ApplicationListViewModel @Inject constructor( fusedAPIRepository.getAppsAndNextClusterUrl(nextClusterUrl, authData).run { val existingPackageNames = newList.map { it.package_name } newList.addAll(first.filter { it.package_name !in existingPackageNames }) - appListLiveData.postValue(newList) + appListLiveData.postValue(Pair(newList, third)) nextClusterUrl = second // set the next "clusterNextPageUrl" } } @@ -132,27 +133,40 @@ class ApplicationListViewModel @Inject constructor( } fun getList(category: String, browseUrl: String, authData: AuthData, source: String) { - if (appListLiveData.value?.isNotEmpty() == true) { + if (appListLiveData.value?.first?.isNotEmpty() == true) { return } viewModelScope.launch(Dispatchers.IO) { - val packageNames = fusedAPIRepository.getAppsListBasedOnCategory( + val appsListData = fusedAPIRepository.getAppsListBasedOnCategory( category, browseUrl, authData, source - ).map { it.package_name } + ) - val applicationDetails = if (!source.contentEquals("PWA")) { + if (appsListData.second != ResultStatus.OK) { + appListLiveData.postValue(Pair(listOf(), appsListData.second)) + return@launch + } + + val applicationDetailsWithStatus = if (!source.contentEquals("PWA")) { + /* + * Optimization: packageNames were not used anywhere else, + * hence moved here. + */ + val packageNames = appsListData.first.map { it.package_name } fusedAPIRepository.getApplicationDetails( packageNames, authData, getOrigin(source) ) } else { - fusedAPIRepository.getAppsListBasedOnCategory(category, browseUrl, authData, source) + /* + * Optimization: Old code was same as the one called above. + */ + appsListData } - appListLiveData.postValue(applicationDetails) + appListLiveData.postValue(applicationDetailsWithStatus) } } 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 27ee9360d030a22209c329d297d047dbdae7933b..0335553b1a9fec7e0dc4a209b4a338ad05c6be5b 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 59e94943538673006824dbf3a85234ad5edc0c1a..2fb4fc7c989e1fd6bc32a96568536e4d8be89efb 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 100e6398244e8a6b00256aa1c03501f450fd49e8..ec72f9150b308b3c46fabdf755f2ff95a350d32c 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 320d237683a489765071bc97a28d255e48e67e9e..1094519744559f0633b6132f7bca100c2cfa09d1 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 0516c34693eaae23507cfd3362cb9ef2ab43b310..035ea8cfdccd12467c3bd553a3b5767ae433d0ad 100644 --- a/app/src/main/java/foundation/e/apps/home/HomeFragment.kt +++ b/app/src/main/java/foundation/e/apps/home/HomeFragment.kt @@ -28,11 +28,13 @@ 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,15 +43,17 @@ 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.parentFragment.TimeoutFragment import foundation.e.apps.utils.modules.CommonUtilsModule.safeNavigate import foundation.e.apps.utils.modules.PWAManagerModule 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 var _binding: FragmentHomeBinding? = null private val binding get() = _binding!! @@ -77,10 +81,49 @@ 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( @@ -109,18 +152,12 @@ 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() } } @@ -129,23 +166,54 @@ class HomeFragment : Fragment(R.layout.fragment_home), FusedAPIInterface { } } - /* - * 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 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, + ) } } - private fun showLoadingShimmer() { + override fun refreshData(authData: AuthData) { + showLoadingUI() + homeViewModel.getHomeScreenData(authData) + } + + 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 @@ -189,6 +257,7 @@ class HomeFragment : Fragment(R.layout.fragment_home), FusedAPIInterface { override fun onResume() { super.onResume() + resetTimeoutDialogLock() binding.shimmerLayout.startShimmer() } 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 48e020bee1c932b2fe24f073c66efd94e0b271f2..5e20a1033bf67cf8b36feb08cba95b55796712a9 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/search/SearchFragment.kt b/app/src/main/java/foundation/e/apps/search/SearchFragment.kt index 7cf486ab4591862ac24369b1fc72ee4a70a1440e..328b301e5a76040250f86cbe36a52549a0509ee0 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 @@ -50,15 +50,17 @@ 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.pkg.PkgManagerModule +import foundation.e.apps.utils.enums.ResultStatus import foundation.e.apps.utils.enums.Status import foundation.e.apps.utils.enums.User +import foundation.e.apps.utils.parentFragment.TimeoutFragment import foundation.e.apps.utils.modules.PWAManagerModule import 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 { @@ -86,6 +88,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) @@ -172,23 +180,41 @@ class SearchFragment : } mainActivityViewModel.downloadList.observe(viewLifecycleOwner) { list -> - - val searchList = searchViewModel.searchResult.value?.toMutableList() - searchList?.let { + val searchList = searchViewModel.searchResult.value?.first?.toMutableList() ?: emptyList() + searchList.let { mainActivityViewModel.updateStatusOfFusedApps(searchList, list) } - searchViewModel.searchResult.value = searchList + + /* + * Done in one line, so that on Ctrl+click on searchResult, + * we can see that it is being updated here. + */ + searchViewModel.searchResult.apply { value = Pair(searchList, value?.second) } } - searchViewModel.searchResult.observe(viewLifecycleOwner) { - if (it.isNullOrEmpty()) { + /* + * Explanation of double observers in HomeFragment.kt + * Modified to check and search only if searchText in not blank, to prevent blank search. + */ + + mainActivityViewModel.internetConnection.observe(viewLifecycleOwner) { + if (searchText.isNotBlank()) { + refreshDataOrRefreshToken(mainActivityViewModel) + } + } + mainActivityViewModel.authData.observe(viewLifecycleOwner) { + if (searchText.isNotBlank()) { + refreshDataOrRefreshToken(mainActivityViewModel) + } + } + + searchViewModel.searchResult.observe(viewLifecycleOwner) { + if (it.first.isNullOrEmpty()) { noAppsFoundLayout?.visibility = View.VISIBLE } else { - - listAdapter?.setData(it) - shimmerLayout?.visibility = View.GONE - recyclerView?.visibility = View.VISIBLE + listAdapter?.setData(it.first) + stopLoadingUI() noAppsFoundLayout?.visibility = View.GONE } listAdapter?.registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() { @@ -196,11 +222,56 @@ class SearchFragment : recyclerView!!.scrollToPosition(0) } }) + if (searchText.isNotBlank() && it.second != ResultStatus.OK) { + /* + * 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) + } + + 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() } @@ -210,7 +281,6 @@ class SearchFragment : } override fun onQueryTextSubmit(query: String?): Boolean { - query?.let { text -> hideKeyboard(activity as Activity) view?.requestFocus() @@ -218,7 +288,11 @@ class SearchFragment : shimmerLayout?.visibility = View.VISIBLE recyclerView?.visibility = View.GONE noAppsFoundLayout?.visibility = View.GONE - mainActivityViewModel.authData.value?.let { searchViewModel.getSearchResults(text, it) } + /* + * Set the search text and call for network result. + */ + searchText = text + refreshDataOrRefreshToken(mainActivityViewModel) } return false } @@ -237,9 +311,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 8749977ad0954339e0e5b549e93c34d50c7907a8..76a821549b8b40f0514abbc76eaf8b1fe3b88523 100644 --- a/app/src/main/java/foundation/e/apps/search/SearchViewModel.kt +++ b/app/src/main/java/foundation/e/apps/search/SearchViewModel.kt @@ -26,6 +26,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.FusedApp +import foundation.e.apps.utils.enums.ResultStatus import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import javax.inject.Inject @@ -36,7 +37,7 @@ class SearchViewModel @Inject constructor( ) : ViewModel() { val searchSuggest: MutableLiveData?> = MutableLiveData() - val searchResult: MutableLiveData> = MutableLiveData() + val searchResult: MutableLiveData, ResultStatus?>> = 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 eb03b02d953800a39b6b5422844900a9c5f1240c..6a4550ec18d8e5df7b75d07f1415485f677cf9be 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,21 @@ 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 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 @@ -42,14 +44,17 @@ 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.updates.manager.UpdatesWorkManager +import foundation.e.apps.utils.enums.ResultStatus import foundation.e.apps.utils.enums.Status import foundation.e.apps.utils.enums.User +import foundation.e.apps.utils.parentFragment.TimeoutFragment +import foundation.e.apps.utils.modules.CommonUtilsModule.safeNavigate import foundation.e.apps.utils.modules.PWAManagerModule import 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 +79,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 @@ -126,30 +131,82 @@ class UpdatesFragment : Fragment(R.layout.fragment_updates), FusedAPIInterface { } 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 } + 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) + } + } + + 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 + } + 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) } } } @@ -189,4 +246,14 @@ 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) + } + + override fun onResume() { + super.onResume() + resetTimeoutDialogLock() + } } 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 a440d34abf33bda646db2f0eab378bd0ab577414..214538eaea479de7dc1e22c7ada9216ab9b29a6b 100644 --- a/app/src/main/java/foundation/e/apps/updates/UpdatesViewModel.kt +++ b/app/src/main/java/foundation/e/apps/updates/UpdatesViewModel.kt @@ -25,6 +25,7 @@ import com.aurora.gplayapi.data.models.AuthData import dagger.hilt.android.lifecycle.HiltViewModel import foundation.e.apps.api.fused.data.FusedApp import foundation.e.apps.updates.manager.UpdatesManagerRepository +import foundation.e.apps.utils.enums.ResultStatus import kotlinx.coroutines.launch import javax.inject.Inject @@ -33,14 +34,21 @@ class UpdatesViewModel @Inject constructor( private val updatesManagerRepository: UpdatesManagerRepository ) : 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 + ) ) } } + + 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 47443ad0803488009ec83c9046e885392d467dfe..03e485126c7e2aee3fda92a4c506d9d01b254ae5 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 51850286e523aeb7d457803ccff4b433eaecc603..649c82261d8e19dd5af6bf3011ccd870b77e4e40 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/UpdatesWorker.kt b/app/src/main/java/foundation/e/apps/updates/manager/UpdatesWorker.kt index bd2dc6fd36534658f40642cd4cd5076bcd582de3..658244662faa4aff046749d17edbd9014908979f 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 @@ -60,7 +60,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. 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 0000000000000000000000000000000000000000..f9f3c5de78a99e3a7744e1fead0fd7e984b92b36 --- /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, +} \ No newline at end of file diff --git a/app/src/main/java/foundation/e/apps/utils/parentFragment/TimeoutFragment.kt b/app/src/main/java/foundation/e/apps/utils/parentFragment/TimeoutFragment.kt new file mode 100644 index 0000000000000000000000000000000000000000..2aa3d485fbbd412223dbf5905af28a710f70a465 --- /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) {} + } + } +} \ No newline at end of file diff --git a/app/src/main/res/navigation/navigation_resource.xml b/app/src/main/res/navigation/navigation_resource.xml index 5fd2fa9caf3161422882a0c7c1376b697dbebfb0..2bacd70dc9775cec97dfafc4a8889ce80d397a52 100644 --- a/app/src/main/res/navigation/navigation_resource.xml +++ b/app/src/main/res/navigation/navigation_resource.xml @@ -75,6 +75,9 @@ +