diff --git a/app/src/main/java/foundation/e/apps/MainActivity.kt b/app/src/main/java/foundation/e/apps/MainActivity.kt index 3c6d100cd5540830cdc0578529feb535999bd281..39874ab768a994d4223b8492ddd9f4db4948869e 100644 --- a/app/src/main/java/foundation/e/apps/MainActivity.kt +++ b/app/src/main/java/foundation/e/apps/MainActivity.kt @@ -90,6 +90,7 @@ class MainActivity : AppCompatActivity() { User.ANONYMOUS -> { if (viewModel.authDataJson.value.isNullOrEmpty() && !viewModel.authRequestRunning) { Log.d(TAG, "Fetching new authentication data") + viewModel.setFirstTokenFetchTime() viewModel.getAuthData() } } @@ -99,6 +100,7 @@ class MainActivity : AppCompatActivity() { User.GOOGLE -> { if (viewModel.authData.value == null && !viewModel.authRequestRunning) { Log.d(TAG, "Fetching new authentication data") + viewModel.setFirstTokenFetchTime() signInViewModel.fetchAuthData() } } @@ -134,7 +136,11 @@ class MainActivity : AppCompatActivity() { if (it != true) { Log.d(TAG, "Authentication data validation failed!") viewModel.destroyCredentials { user -> - generateAuthDataBasedOnUserType(user) + if (viewModel.isTimeEligibleForTokenRefresh()) { + generateAuthDataBasedOnUserType(user) + } else { + Log.d(TAG, "Timeout validating auth data!") + } } } else { Log.d(TAG, "Authentication data is valid!") diff --git a/app/src/main/java/foundation/e/apps/MainActivityViewModel.kt b/app/src/main/java/foundation/e/apps/MainActivityViewModel.kt index 19d8bbc30eecc46cc696067ef35e08722f73a6f5..d3023bd80eef9db777ea1fdebfaf0f442ab82a69 100644 --- a/app/src/main/java/foundation/e/apps/MainActivityViewModel.kt +++ b/app/src/main/java/foundation/e/apps/MainActivityViewModel.kt @@ -18,14 +18,18 @@ package foundation.e.apps -import android.app.AlertDialog +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 import androidx.core.graphics.drawable.toBitmap import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData @@ -37,16 +41,19 @@ import com.aurora.gplayapi.data.models.AuthData import com.aurora.gplayapi.exceptions.ApiException import com.google.gson.Gson import dagger.hilt.android.lifecycle.HiltViewModel +import foundation.e.apps.api.fused.FusedAPIImpl import foundation.e.apps.api.fused.FusedAPIRepository import foundation.e.apps.api.fused.data.FusedApp import foundation.e.apps.manager.database.fusedDownload.FusedDownload import foundation.e.apps.manager.fused.FusedManagerRepository import foundation.e.apps.manager.pkg.PkgManagerModule import foundation.e.apps.manager.workmanager.InstallWorkManager +import foundation.e.apps.settings.SettingsFragment import foundation.e.apps.utils.enums.Origin import foundation.e.apps.utils.enums.Status import foundation.e.apps.utils.enums.Type import foundation.e.apps.utils.enums.User +import foundation.e.apps.utils.modules.CommonUtilsModule.timeoutDurationInMillis import foundation.e.apps.utils.modules.DataStoreModule import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -76,6 +83,116 @@ class MainActivityViewModel @Inject constructor( val purchaseDeclined: MutableLiveData = MutableLiveData() var authRequestRunning = false + /* + * Store the time when auth data is fetched for the first time. + * If we try to fetch auth data after timeout, then don't allow it. + * + * Issue: https://gitlab.e.foundation/e/backlog/-/issues/5404 + */ + 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 @@ -92,6 +209,26 @@ class MainActivityViewModel @Inject constructor( private const val TAG = "MainActivityViewModel" } + fun setFirstTokenFetchTime() { + firstAuthDataFetchTime = SystemClock.uptimeMillis() + } + + fun isTimeEligibleForTokenRefresh(): Boolean { + return (SystemClock.uptimeMillis() - firstAuthDataFetchTime) <= timeoutDurationInMillis + } + + /* + * This method resets the last recorded token fetch time. + * Then it posts authValidity as false. This causes the observer in MainActivity to destroyCredentials + * and fetch new token. + * + * Issue: https://gitlab.e.foundation/e/backlog/-/issues/5404 + */ + fun retryFetchingTokenAfterTimeout() { + setFirstTokenFetchTime() + authValidity.postValue(false) + } + fun getAuthData() { if (!authRequestRunning) { authRequestRunning = true 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 2e54a41839a283fb4e40b7ef71ed355a54621a13..8130bf9ae6fc480db5c45f3106073c803a498c07 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 @@ -20,6 +20,7 @@ package foundation.e.apps.api.fused import android.content.Context import android.text.format.Formatter +import android.util.Log import com.aurora.gplayapi.Constants import com.aurora.gplayapi.SearchSuggestEntry import com.aurora.gplayapi.data.models.App @@ -46,8 +47,11 @@ 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.modules.CommonUtilsModule.timeoutDurationInMillis import foundation.e.apps.utils.modules.PWAManagerModule import foundation.e.apps.utils.modules.PreferenceManagerModule +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.withTimeout import javax.inject.Inject import javax.inject.Singleton @@ -63,38 +67,96 @@ class FusedAPIImpl @Inject constructor( companion object { private const val CATEGORY_TITLE_REPLACEABLE_CONJUNCTION = "&" - private const val APP_TYPE_ANY = "any" - private const val APP_TYPE_OPEN = "open" - private const val APP_TYPE_PWA = "pwa" + /* + * Removing "private" access specifier to allow access in + * MainActivityViewModel.timeoutAlertDialog + * + * Issue: https://gitlab.e.foundation/e/backlog/-/issues/5404 + */ + const val APP_TYPE_ANY = "any" + const val APP_TYPE_OPEN = "open" + const val APP_TYPE_PWA = "pwa" private const val CATEGORY_OPEN_GAMES_ID = "game_open_games" private const val CATEGORY_OPEN_GAMES_TITLE = "Open games" } private var TAG = FusedAPIImpl::class.java.simpleName - suspend fun getHomeScreenData(authData: AuthData): List { - val list = mutableListOf() + /** + * Pass application source type along with list of apps. + * Application source type may change in case of timeout of GPlay/cleanapk api. + * + * 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 + */ + suspend fun getHomeScreenData(authData: AuthData): Pair, String> { val preferredApplicationType = preferenceManagerModule.preferredApplicationType() + val initialData = getHomeScreenDataBasedOnApplicationType(authData, preferredApplicationType) + if (isFusedHomesEmpty(initialData.first)) { + Log.d(TAG, "Received empty home data.") + } + return initialData + } - if (preferredApplicationType != APP_TYPE_ANY) { - val response = if (preferredApplicationType == APP_TYPE_OPEN) { - cleanAPKRepository.getHomeScreenData( - CleanAPKInterface.APP_TYPE_ANY, - CleanAPKInterface.APP_SOURCE_FOSS - ).body() - } else { - cleanAPKRepository.getHomeScreenData( - CleanAPKInterface.APP_TYPE_PWA, - CleanAPKInterface.APP_SOURCE_ANY - ).body() - } - response?.home?.let { - list.addAll(generateCleanAPKHome(it, preferredApplicationType)) + /** + * Check if list in all the FusedHome is empty. + * If any list is not empty, send false. + * Else (if all lists are empty) send true. + */ + fun isFusedHomesEmpty(fusedHomes: List): Boolean { + fusedHomes.forEach { + if (it.list.isNotEmpty()) return false + } + return true + } + + /* + * Offload fetching application to a different method to dynamically fallback to a different + * app source if the user selected app source times out. + * + * Issue: https://gitlab.e.foundation/e/backlog/-/issues/5404 + */ + private suspend fun getHomeScreenDataBasedOnApplicationType( + authData: AuthData, + applicationType: String + ): Pair, String> { + val list = mutableListOf() + try { + /* + * Each category of home apps (example "Top Free Apps") will have its own timeout. + * Fetching 6 such categories will have a total timeout to 2 mins 30 seconds + * (considering each category having 25 seconds timeout). + * + * To prevent waiting so long and fail early, use withTimeout{}. + */ + withTimeout(timeoutDurationInMillis) { + if (applicationType != APP_TYPE_ANY) { + val response = if (applicationType == APP_TYPE_OPEN) { + cleanAPKRepository.getHomeScreenData( + CleanAPKInterface.APP_TYPE_ANY, + CleanAPKInterface.APP_SOURCE_FOSS + ).body() + } else { + cleanAPKRepository.getHomeScreenData( + CleanAPKInterface.APP_TYPE_PWA, + CleanAPKInterface.APP_SOURCE_ANY + ).body() + } + response?.home?.let { + list.addAll(generateCleanAPKHome(it, applicationType)) + } + } else { + list.addAll(fetchGPlayHome(authData)) + } } - } else { - list.addAll(fetchGPlayHome(authData)) + } catch (e: TimeoutCancellationException) { + e.printStackTrace() + Log.d(TAG, "Timed out fetching home data for type: $applicationType") + } catch (e: Exception) { + e.printStackTrace() } - return list + return Pair(list, applicationType) } suspend fun getCategoriesList(type: Category.Type, authData: AuthData): List { 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 f5f2ea17f50d14a05f7bf7827609a5d9f8f05cb4..745dd0773b25bb9ab2ef2ee11be3896266e83ae7 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 @@ -34,10 +34,14 @@ import javax.inject.Singleton class FusedAPIRepository @Inject constructor( private val fusedAPIImpl: FusedAPIImpl ) { - suspend fun getHomeScreenData(authData: AuthData): List { + suspend fun getHomeScreenData(authData: AuthData): Pair, String> { return fusedAPIImpl.getHomeScreenData(authData) } + fun isFusedHomesEmpty(fusedHomes: List): Boolean { + return fusedAPIImpl.isFusedHomesEmpty(fusedHomes) + } + suspend fun validateAuthData(authData: AuthData): Boolean { return fusedAPIImpl.validateAuthData(authData) } diff --git a/app/src/main/java/foundation/e/apps/api/gplay/utils/GPlayHttpClient.kt b/app/src/main/java/foundation/e/apps/api/gplay/utils/GPlayHttpClient.kt index 10a08423938d9e3d80758d15e07f8f65745382ab..6411d870fa2029f221c2b358b0982169bcb9ab71 100644 --- a/app/src/main/java/foundation/e/apps/api/gplay/utils/GPlayHttpClient.kt +++ b/app/src/main/java/foundation/e/apps/api/gplay/utils/GPlayHttpClient.kt @@ -23,6 +23,7 @@ import android.util.Log import com.aurora.gplayapi.data.models.PlayResponse import com.aurora.gplayapi.network.IHttpClient import foundation.e.apps.BuildConfig +import foundation.e.apps.utils.modules.CommonUtilsModule.timeoutDurationInMillis import okhttp3.Cache import okhttp3.Headers.Companion.toHeaders import okhttp3.HttpUrl @@ -51,10 +52,11 @@ class GPlayHttpClient @Inject constructor( } private val okHttpClient = OkHttpClient().newBuilder() - .connectTimeout(25, TimeUnit.SECONDS) - .readTimeout(25, TimeUnit.SECONDS) - .writeTimeout(25, TimeUnit.SECONDS) - .retryOnConnectionFailure(true) + .connectTimeout(timeoutDurationInMillis, TimeUnit.MILLISECONDS) + .readTimeout(timeoutDurationInMillis, TimeUnit.MILLISECONDS) + .writeTimeout(timeoutDurationInMillis, TimeUnit.MILLISECONDS) + .callTimeout(timeoutDurationInMillis, TimeUnit.MILLISECONDS) + .retryOnConnectionFailure(false) .followRedirects(true) .followSslRedirects(true) .cache(cache) 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 d4f0dfb882dcef487501775e7cef8f402cbbe659..4ece61ddec66563faf211dc6b63f1f462f355bad 100644 --- a/app/src/main/java/foundation/e/apps/home/HomeFragment.kt +++ b/app/src/main/java/foundation/e/apps/home/HomeFragment.kt @@ -77,13 +77,9 @@ class HomeFragment : Fragment(R.layout.fragment_home), FusedAPIInterface { } } - mainActivityViewModel.internetConnection.observe(viewLifecycleOwner) { hasInternet -> - mainActivityViewModel.authData.observe(viewLifecycleOwner) { authData -> - if (hasInternet) { - authData?.let { - homeViewModel.getHomeScreenData(authData) - } - } + mainActivityViewModel.internetConnection.observe(viewLifecycleOwner) { + mainActivityViewModel.authData.observe(viewLifecycleOwner) { + refreshHomeData() } } @@ -113,9 +109,18 @@ class HomeFragment : Fragment(R.layout.fragment_home), FusedAPIInterface { } homeViewModel.homeScreenData.observe(viewLifecycleOwner) { - homeParentRVAdapter.setData(it) binding.shimmerLayout.visibility = View.GONE binding.parentRV.visibility = View.VISIBLE + if (!homeViewModel.isFusedHomesEmpty(it.first)) { + homeParentRVAdapter.setData(it.first) + } else if (!mainActivityViewModel.isTimeoutDialogDisplayed()) { + mainActivityViewModel.displayTimeoutAlertDialog(requireActivity(), { + showLoadingShimmer() + mainActivityViewModel.retryFetchingTokenAfterTimeout() + }, { + openSettings() + }, it.second) + } } appProgressViewModel.downloadProgress.observe(viewLifecycleOwner) { @@ -123,6 +128,23 @@ 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) + } + } + } + + private fun showLoadingShimmer() { + binding.shimmerLayout.visibility = View.VISIBLE + binding.parentRV.visibility = View.GONE + } + private fun updateProgressOfDownloadingAppItemViews( homeParentRVAdapter: HomeParentRVAdapter, downloadProgress: DownloadProgress @@ -200,4 +222,9 @@ class HomeFragment : Fragment(R.layout.fragment_home), FusedAPIInterface { ?.safeNavigate(R.id.homeFragment, R.id.action_homeFragment_to_signInFragment) } } + + private fun openSettings() { + view?.findNavController() + ?.safeNavigate(R.id.homeFragment, R.id.action_homeFragment_to_SettingsFragment) + } } 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 29b2514c0d19e49495ebb31fbbe3479316f78539..48e020bee1c932b2fe24f073c66efd94e0b271f2 100644 --- a/app/src/main/java/foundation/e/apps/home/HomeViewModel.kt +++ b/app/src/main/java/foundation/e/apps/home/HomeViewModel.kt @@ -33,11 +33,21 @@ class HomeViewModel @Inject constructor( private val fusedAPIRepository: FusedAPIRepository ) : ViewModel() { - var homeScreenData: MutableLiveData> = MutableLiveData() + /* + * Hold list of applications, as well as application source type. + * Source type may change from user selected preference in case of timeout. + * + * Issue: https://gitlab.e.foundation/e/backlog/-/issues/5404 + */ + var homeScreenData: MutableLiveData, String>> = MutableLiveData() fun getHomeScreenData(authData: AuthData) { viewModelScope.launch { homeScreenData.postValue(fusedAPIRepository.getHomeScreenData(authData)) } } + + fun isFusedHomesEmpty(fusedHomes: List): Boolean { + return fusedAPIRepository.isFusedHomesEmpty(fusedHomes) + } } diff --git a/app/src/main/java/foundation/e/apps/utils/modules/CommonUtilsModule.kt b/app/src/main/java/foundation/e/apps/utils/modules/CommonUtilsModule.kt index 12af86294e6e096a8d68624c078474d878b5332f..e00439dbba2eef0b15ec99764f91f1eda20de02c 100644 --- a/app/src/main/java/foundation/e/apps/utils/modules/CommonUtilsModule.kt +++ b/app/src/main/java/foundation/e/apps/utils/modules/CommonUtilsModule.kt @@ -44,6 +44,7 @@ import javax.inject.Singleton object CommonUtilsModule { val LIST_OF_NULL = listOf("null") + const val timeoutDurationInMillis: Long = 25000 /** * Check supported ABIs by device diff --git a/app/src/main/res/navigation/navigation_resource.xml b/app/src/main/res/navigation/navigation_resource.xml index 72834907f2aaaf72ce1bb344f5be676e95f6f4eb..5fd2fa9caf3161422882a0c7c1376b697dbebfb0 100644 --- a/app/src/main/res/navigation/navigation_resource.xml +++ b/app/src/main/res/navigation/navigation_resource.xml @@ -42,6 +42,12 @@ app:launchSingleTop="true" app:popUpTo="@+id/navigation_resource" app:popUpToInclusive="true" /> + Can\'t connect! Please check your internet connection and try again + + + Timeout fetching applications! + Some network issue is preventing fetching all applications. + \n\nOpen settings to look for Open source apps or PWAs only. + + Some network issue is preventing fetching all applications. + Open Settings %1$s]]>