diff --git a/app/src/main/java/foundation/e/apps/MainActivity.kt b/app/src/main/java/foundation/e/apps/MainActivity.kt index 4c023ae44405be51e48e09c37c9d8104eb92ee7c..1abe7f4f77c695f19ad258bda96b2979add68f8e 100644 --- a/app/src/main/java/foundation/e/apps/MainActivity.kt +++ b/app/src/main/java/foundation/e/apps/MainActivity.kt @@ -25,9 +25,9 @@ import android.os.Environment import android.os.StatFs import android.os.storage.StorageManager import android.view.View -import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.distinctUntilChanged import androidx.lifecycle.lifecycleScope import androidx.navigation.NavController import androidx.navigation.NavOptions @@ -35,12 +35,15 @@ import androidx.navigation.findNavController import androidx.navigation.fragment.NavHostFragment import androidx.navigation.ui.NavigationUI import androidx.navigation.ui.setupWithNavController +import com.aurora.gplayapi.data.models.AuthData import com.aurora.gplayapi.exceptions.ApiException import com.google.android.material.bottomnavigation.BottomNavigationView import com.google.android.material.snackbar.Snackbar import dagger.hilt.android.AndroidEntryPoint import foundation.e.apps.application.subFrags.ApplicationDialogFragment import foundation.e.apps.databinding.ActivityMainBinding +import foundation.e.apps.login.AuthObject +import foundation.e.apps.login.LoginViewModel import foundation.e.apps.manager.database.fusedDownload.FusedDownload import foundation.e.apps.manager.workmanager.InstallWorkManager import foundation.e.apps.purchase.AppPurchaseFragmentDirections @@ -50,11 +53,12 @@ import foundation.e.apps.updates.UpdatesNotifier import foundation.e.apps.utils.enums.Status import foundation.e.apps.utils.eventBus.AppEvent import foundation.e.apps.utils.eventBus.EventBus +import foundation.e.apps.utils.exceptions.GPlayValidationException import foundation.e.apps.utils.modules.CommonUtilsModule -import foundation.e.apps.utils.parentFragment.TimeoutFragment import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.filter import kotlinx.coroutines.launch +import org.json.JSONObject import timber.log.Timber import java.io.File import java.util.UUID @@ -62,6 +66,7 @@ import java.util.UUID @AndroidEntryPoint class MainActivity : AppCompatActivity() { private lateinit var signInViewModel: SignInViewModel + private lateinit var loginViewModel: LoginViewModel private lateinit var binding: ActivityMainBinding private val TAG = MainActivity::class.java.simpleName private lateinit var viewModel: MainActivityViewModel @@ -83,6 +88,7 @@ class MainActivity : AppCompatActivity() { viewModel = ViewModelProvider(this)[MainActivityViewModel::class.java] signInViewModel = ViewModelProvider(this)[SignInViewModel::class.java] + loginViewModel = ViewModelProvider(this)[LoginViewModel::class.java] // navOptions and activityNavController for TOS and SignIn Fragments val navOptions = NavOptions.Builder() @@ -90,9 +96,11 @@ class MainActivity : AppCompatActivity() { .build() navOptions.shouldLaunchSingleTop() - viewModel.tocStatus.observe(this) { + viewModel.tocStatus.distinctUntilChanged().observe(this) { if (it != true) { navController.navigate(R.id.TOSFragment, null, navOptions) + } else { + loginViewModel.startLoginFlow() } } @@ -101,37 +109,34 @@ class MainActivity : AppCompatActivity() { if (isInternetAvailable) { binding.noInternet.visibility = View.GONE binding.fragment.visibility = View.VISIBLE - - // Watch and refresh authentication data - if (viewModel.authDataJson.value == null) { - viewModel.authDataJson.observe(this) { - viewModel.handleAuthDataJson() - } - } } } - viewModel.userType.observe(this) { user -> - viewModel.handleAuthDataJson() - } - - if (signInViewModel.authLiveData.value == null) { - signInViewModel.authLiveData.observe(this) { - viewModel.updateAuthData(it) + loginViewModel.authObjects.distinctUntilChanged().observe(this) { + when { + it == null -> return@observe + it.isEmpty() -> { + // No auth type defined means user has not logged in yet + // Pop back stack to prevent showing TOSFragment on pressing back button. + navController.popBackStack() + navController.navigate(R.id.signInFragment) + } + else -> {} } - } - viewModel.errorAuthResponse.observe(this) { - onSignInError() - } - - viewModel.authValidity.observe(this) { - viewModel.handleAuthValidity(it) { - Timber.d("Timeout validating auth data!") - val lastFragment = navHostFragment.childFragmentManager.fragments[0] - if (lastFragment is TimeoutFragment) { - Timber.d("Displaying timeout from MainActivity on fragment: " + lastFragment.javaClass.name) - lastFragment.onTimeout() + it.find { it is AuthObject.GPlayAuth }?.result?.run { + if (isSuccess()) { + viewModel.gPlayAuthData = data as AuthData + } else if (exception is GPlayValidationException) { + val email = otherPayload.toString() + val descriptionJson = JSONObject().apply { + put("versionName", BuildConfig.VERSION_NAME) + put("versionCode", BuildConfig.VERSION_CODE) + put("debuggable", BuildConfig.DEBUG) + put("device", Build.DEVICE) + put("api", Build.VERSION.SDK_INT) + } + viewModel.uploadFaultyTokenToEcloud(email, descriptionJson.toString()) } } } @@ -331,19 +336,6 @@ class MainActivity : AppCompatActivity() { } } - private fun onSignInError() { - AlertDialog.Builder(this).apply { - setTitle(R.string.sign_in_failed_title) - setMessage(R.string.sign_in_failed_desc) - setPositiveButton(R.string.retry) { _, _ -> - viewModel.retryFetchingTokenAfterTimeout() - } - setNegativeButton(R.string.logout) { _, _ -> - viewModel.postFalseAuthValidity() - } - }.show() - } - private fun getAvailableInternalMemorySize(): Long { val path: File = Environment.getDataDirectory() val stat = StatFs(path.path) diff --git a/app/src/main/java/foundation/e/apps/MainActivityViewModel.kt b/app/src/main/java/foundation/e/apps/MainActivityViewModel.kt index 373c5903e352f7b737a8a5d8741daf8e57577ec6..f1b93d07ea1dbf263e3cfc7d5090aff1024c04e1 100644 --- a/app/src/main/java/foundation/e/apps/MainActivityViewModel.kt +++ b/app/src/main/java/foundation/e/apps/MainActivityViewModel.kt @@ -22,7 +22,6 @@ import android.content.Context import android.content.Intent import android.graphics.Bitmap import android.os.Build -import android.os.SystemClock import android.util.Base64 import android.widget.ImageView import androidx.annotation.RequiresApi @@ -35,16 +34,12 @@ import androidx.lifecycle.asLiveData import androidx.lifecycle.liveData import androidx.lifecycle.viewModelScope import com.aurora.gplayapi.data.models.AuthData -import com.aurora.gplayapi.data.models.PlayResponse import com.aurora.gplayapi.exceptions.ApiException -import com.google.gson.Gson import dagger.hilt.android.lifecycle.HiltViewModel import foundation.e.apps.api.cleanapk.blockedApps.BlockedAppRepository import foundation.e.apps.api.ecloud.EcloudRepository import foundation.e.apps.api.fused.FusedAPIRepository import foundation.e.apps.api.fused.data.FusedApp -import foundation.e.apps.api.gplay.utils.AC2DMTask -import foundation.e.apps.api.gplay.utils.AC2DMUtil import foundation.e.apps.manager.database.fusedDownload.FusedDownload import foundation.e.apps.manager.fused.FusedManagerRepository import foundation.e.apps.manager.pkg.PkgManagerModule @@ -54,21 +49,16 @@ import foundation.e.apps.utils.enums.Type import foundation.e.apps.utils.enums.User import foundation.e.apps.utils.enums.isInitialized import foundation.e.apps.utils.enums.isUnFiltered -import foundation.e.apps.utils.modules.CommonUtilsModule.NETWORK_CODE_SUCCESS -import foundation.e.apps.utils.modules.CommonUtilsModule.timeoutDurationInMillis import foundation.e.apps.utils.modules.DataStoreModule import foundation.e.apps.utils.modules.PWAManagerModule import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import ru.beryukhov.reactivenetwork.ReactiveNetwork -import timber.log.Timber import java.io.ByteArrayOutputStream import javax.inject.Inject @HiltViewModel class MainActivityViewModel @Inject constructor( - private val gson: Gson, private val dataStoreModule: DataStoreModule, private val fusedAPIRepository: FusedAPIRepository, private val fusedManagerRepository: FusedManagerRepository, @@ -76,37 +66,16 @@ class MainActivityViewModel @Inject constructor( private val pwaManagerModule: PWAManagerModule, private val ecloudRepository: EcloudRepository, private val blockedAppRepository: BlockedAppRepository, - private val aC2DMTask: AC2DMTask, ) : ViewModel() { - val authDataJson: LiveData = dataStoreModule.authData.asLiveData() val tocStatus: LiveData = dataStoreModule.tocStatus.asLiveData() - val userType: LiveData = dataStoreModule.userType.asLiveData() - private var _authData: MutableLiveData = MutableLiveData() - val authData: LiveData = _authData - val authValidity: MutableLiveData = MutableLiveData() private val _purchaseAppLiveData: MutableLiveData = MutableLiveData() val purchaseAppLiveData: LiveData = _purchaseAppLiveData val isAppPurchased: MutableLiveData = MutableLiveData() val purchaseDeclined: MutableLiveData = MutableLiveData() - var authRequestRunning = false - /* - * If this live data is populated, it means Google sign in failed. - * Issue: https://gitlab.e.foundation/e/backlog/-/issues/5709 - */ - val errorAuthResponse = MutableLiveData() - - /* - * 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 - - var isTokenValidationCompletedOnce = false + var gPlayAuthData = AuthData("", "") // Downloads val downloadList = fusedManagerRepository.getDownloadLiveList() @@ -116,9 +85,6 @@ class MainActivityViewModel @Inject constructor( private val _errorMessageStringResource = MutableLiveData() val errorMessageStringResource: LiveData = _errorMessageStringResource - /* - * Authentication related functions - */ companion object { private const val TAG = "MainActivityViewModel" @@ -126,270 +92,19 @@ class MainActivityViewModel @Inject constructor( } fun getUser(): User { - return User.valueOf(userType.value ?: User.UNAVAILABLE.name) - } - - private fun setFirstTokenFetchTime() { - if (firstAuthDataFetchTime == 0L) { - firstAuthDataFetchTime = SystemClock.uptimeMillis() - } + return dataStoreModule.getUserType() } - private fun isTimeEligibleForTokenRefresh(): Boolean { - return (SystemClock.uptimeMillis() - firstAuthDataFetchTime) <= timeoutDurationInMillis + fun getUserEmail(): String { + return dataStoreModule.getEmail() } - /* - * 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() { - firstAuthDataFetchTime = 0 - setFirstTokenFetchTime() - if (isUserTypeGoogle()) { - /* - * Change done to show sign in error dialog for Google login. - * Issue: https://gitlab.e.foundation/e/backlog/-/issues/5709 - */ - if (authDataJson.value.isNullOrEmpty()) { - generateAuthDataBasedOnUserType(User.GOOGLE.name) - } else { - validateAuthData() - } - } else { - postFalseAuthValidity() - } - } - - fun uploadFaultyTokenToEcloud(description: String) { + fun uploadFaultyTokenToEcloud(email: String, description: String = "") { viewModelScope.launch { - authData.value?.let { authData -> - val email: String = authData.run { - if (email != "null") email - else userProfile?.email ?: "null" - } - ecloudRepository.uploadFaultyEmail(email, description) - } + ecloudRepository.uploadFaultyEmail(email, description) } } - fun getAuthData() { - if (!authRequestRunning) { - authRequestRunning = true - viewModelScope.launch { - /* - * 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 - postFalseAuthValidity() - } - } - } - } - - fun updateAuthData(authData: AuthData) { - _authData.value = authData - } - - fun destroyCredentials(regenerateFunction: ((user: String) -> Unit)?) { - viewModelScope.launch { - /* - * Issue: https://gitlab.e.foundation/e/backlog/-/issues/5168 - * - * Now destroyCredentials() no longer removes the user type from data store. - * (i.e. Google login or Anonymous). - * - If the type is User.ANONYMOUS then we do not prompt the user to login again, - * we directly generate new auth data; which is the main Gitlab issue described above. - * - If not anonymous user, i.e. type is User.GOOGLE, in that case we clear - * the USERTYPE value. This causes HomeFragment.onTosAccepted() to open - * SignInFragment as we need fresh login from the user. - */ - dataStoreModule.destroyCredentials() - if (regenerateFunction != null) { - dataStoreModule.userType.collect { user -> - if (!user.isBlank() && User.valueOf(user) == User.ANONYMOUS) { - Timber.d("Regenerating auth data for Anonymous user") - regenerateFunction(user) - } else { - Timber.d("Ask Google user to log in again") - dataStoreModule.clearUserType() - } - } - } - } - } - - fun generateAuthData() { - val data = jsonToAuthData() - _authData.value = data - } - - private fun jsonToAuthData() = gson.fromJson(authDataJson.value, AuthData::class.java) - - fun validateAuthData() { - viewModelScope.launch { - jsonToAuthData()?.let { - val validityResponse = getAuthValidityResponse(it) - if (isUserTypeGoogle() && validityResponse.code != NETWORK_CODE_SUCCESS) { - /* - * Change done to show sign in error dialog for Google login. - * Issue: https://gitlab.e.foundation/e/backlog/-/issues/5709 - */ - errorAuthResponse.postValue(validityResponse) - } else { - authValidity.postValue(validityResponse.isSuccessful) - } - authRequestRunning = false - } - } - } - - // Issue: https://gitlab.e.foundation/e/backlog/-/issues/5709 - fun isUserTypeGoogle(): Boolean { - return userType.value == User.GOOGLE.name - } - - /** - * Useful to destroy credentials. - */ - fun postFalseAuthValidity() { - authValidity.postValue(false) - } - - fun handleAuthDataJson() { - val user = userType.value - val json = authDataJson.value - - if (user == null || json == null) { - return - } - Timber.d(">>> handleAuthDataJson: internet: ${internetConnection.value}") - if (!isUserLoggedIn(user, json)) { - generateAuthDataBasedOnUserType(user) - } else if (isEligibleToValidateJson(json) && internetConnection.value == true) { - validateAuthData() - Timber.d(">>> Authentication data is available!") - } - } - - private fun isUserLoggedIn(user: String, json: String) = - user.isNotEmpty() && !user.contentEquals(User.UNAVAILABLE.name) && json.isNotEmpty() - - private fun isEligibleToValidateJson(authDataJson: String?) = - !authDataJson.isNullOrEmpty() && !userType.value.isNullOrEmpty() && !userType.value.contentEquals( - User.UNAVAILABLE.name - ) && authValidity.value != true - - fun handleAuthValidity(isValid: Boolean, handleTimeoOut: () -> Unit) { - if (isGoogleLoginRunning) { - return - } - isTokenValidationCompletedOnce = true - if (isValid) { - Timber.d("Authentication data is valid!") - generateAuthData() - return - } - Timber.d(">>> Authentication data validation failed!") - destroyCredentials { user -> - if (isTimeEligibleForTokenRefresh()) { - generateAuthDataBasedOnUserType(user) - } else { - handleTimeoOut() - } - } - } - - private fun generateAuthDataBasedOnUserType(user: String) { - if (user.isEmpty() || tocStatus.value == false || isGoogleLoginRunning) { - return - } - when (User.valueOf(user)) { - User.ANONYMOUS -> { - if (authDataJson.value.isNullOrEmpty() && !authRequestRunning) { - Timber.d(">>> Fetching new authentication data") - setFirstTokenFetchTime() - getAuthData() - } - } - User.UNAVAILABLE -> { - destroyCredentials(null) - } - User.GOOGLE -> { - if (authData.value == null && !authRequestRunning) { - Timber.d(">>> Fetching new authentication data") - setFirstTokenFetchTime() - doFetchAuthData() - } - } - } - } - - private suspend fun doFetchAuthData(email: String, oauthToken: String) { - var responseMap: Map - withContext(Dispatchers.IO) { - val response = aC2DMTask.getAC2DMResponse(email, oauthToken) - responseMap = if (response.isSuccessful) { - AC2DMUtil.parseResponse(String(response.responseBytes)) - } else { - mapOf() - } - if (isUserTypeGoogle() && response.code != NETWORK_CODE_SUCCESS) { - /* - * For google login, the email and aasToken gets stored when - * we login through the webview, but that does not mean we have a valid authData. - * - * For first login, control flow is as below: - * In MainActivity, from userType observer -> handleAuthDataJson - * -> generateAuthDataBasedOnUserType -> doFetchAuthData -> this function - * - * If for first google login, google sign in portal was available - * but android.clients.google.com is unreachable, then responseMap is blank. - * - * We see validateAuthData is never called (which had a check for incorrect response) - * Hence we have to check the response code is NETWORK_CODE_SUCCESS (200) or not - * and show the Google sign in failed dialog. - * - * Issue: https://gitlab.e.foundation/e/backlog/-/issues/5709 - */ - errorAuthResponse.postValue(response) - return@withContext - } - responseMap["Token"]?.let { - if (fusedAPIRepository.fetchAuthData(email, it) == null) { - dataStoreModule.clearUserType() - _errorMessageStringResource.value = R.string.unknown_error - } - } - } - } - - private fun doFetchAuthData() { - viewModelScope.launch { - isGoogleLoginRunning = true - val email = dataStoreModule.getEmail() - val oauthToken = dataStoreModule.getAASToken() - if (email.isNotEmpty() && oauthToken.isNotEmpty()) { - doFetchAuthData(email, oauthToken) - } - isGoogleLoginRunning = false - } - } - - private suspend fun getAuthValidityResponse(authData: AuthData): PlayResponse { - return fusedAPIRepository.validateAuthData(authData) - } - /* * Notification functions */ @@ -416,7 +131,7 @@ class MainActivityViewModel @Inject constructor( * Issue: https://gitlab.e.foundation/e/os/backlog/-/issues/266 */ fun shouldShowPaidAppsSnackBar(app: FusedApp): Boolean { - if (!app.isFree && authData.value?.isAnonymous == true) { + if (!app.isFree && gPlayAuthData.isAnonymous) { _errorMessageStringResource.value = R.string.paid_app_anonymous_message return true } @@ -460,7 +175,7 @@ class MainActivityViewModel @Inject constructor( */ fun verifyUiFilter(fusedApp: FusedApp, method: () -> Unit) { viewModelScope.launch { - val authData = authData.value + val authData = gPlayAuthData if (fusedApp.filterLevel.isInitialized()) { method() } else { @@ -553,7 +268,7 @@ class MainActivityViewModel @Inject constructor( suspend fun updateAwaitingForPurchasedApp(packageName: String): FusedDownload? { val fusedDownload = fusedManagerRepository.getFusedDownload(packageName = packageName) - authData.value?.let { + gPlayAuthData.let { if (!it.isAnonymous) { try { fusedAPIRepository.updateFusedDownloadWithDownloadingInfo( @@ -594,7 +309,7 @@ class MainActivityViewModel @Inject constructor( fusedDownload: FusedDownload ) { val downloadList = mutableListOf() - authData.value?.let { + gPlayAuthData.let { if (app.type == Type.PWA) { downloadList.add(app.url) fusedDownload.downloadURLList = downloadList diff --git a/app/src/main/java/foundation/e/apps/api/ResultSupreme.kt b/app/src/main/java/foundation/e/apps/api/ResultSupreme.kt index 1528035137a6ab1d773317698d5071f0669650b3..7a4763301b75172e3c27af5d3d68653e7ae3e96a 100644 --- a/app/src/main/java/foundation/e/apps/api/ResultSupreme.kt +++ b/app/src/main/java/foundation/e/apps/api/ResultSupreme.kt @@ -70,7 +70,7 @@ sealed class ResultSupreme { * @param message A String message to log or display to the user. * @param exception Optional exception from try-catch block. */ - constructor(message: String, exception: Exception = Exception()) : this() { + constructor(message: String, exception: Exception? = null) : this() { this.message = message this.exception = exception } @@ -91,6 +91,11 @@ sealed class ResultSupreme { var data: T? = null private set + /** + * Any other information that needs to be transmitted. + */ + var otherPayload: Any? = null + /** * A custom string message for logging or displaying to the user. */ @@ -99,7 +104,7 @@ sealed class ResultSupreme { /** * Exception from try-catch block for error cases. */ - var exception: Exception = Exception() + var exception: Exception? = null fun isValidData() = data != null @@ -121,7 +126,7 @@ sealed class ResultSupreme { status: ResultStatus, data: T? = null, message: String = "", - exception: Exception = Exception(), + exception: Exception? = null, ): ResultSupreme { val resultObject = when { status == ResultStatus.OK && data != null -> Success(data) 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 38884b92d2afe347e70f190f636f9503a515e9f6..6983e48af6d7c2ba6422462c4fe87b518b7c8e1e 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 @@ -29,7 +29,6 @@ import com.aurora.gplayapi.data.models.App import com.aurora.gplayapi.data.models.Artwork import com.aurora.gplayapi.data.models.AuthData import com.aurora.gplayapi.data.models.Category -import com.aurora.gplayapi.data.models.PlayResponse import com.aurora.gplayapi.data.models.StreamBundle import com.aurora.gplayapi.data.models.StreamCluster import com.aurora.gplayapi.helpers.TopChartsHelper @@ -50,6 +49,7 @@ import foundation.e.apps.api.gplay.GPlayAPIRepository import foundation.e.apps.home.model.HomeChildFusedAppDiffUtil import foundation.e.apps.manager.database.fusedDownload.FusedDownload import foundation.e.apps.manager.pkg.PkgManagerModule +import foundation.e.apps.utils.Constants.timeoutDurationInMillis import foundation.e.apps.utils.enums.AppTag import foundation.e.apps.utils.enums.FilterLevel import foundation.e.apps.utils.enums.Origin @@ -57,7 +57,6 @@ import foundation.e.apps.utils.enums.ResultStatus import foundation.e.apps.utils.enums.Status import foundation.e.apps.utils.enums.Type import foundation.e.apps.utils.enums.isUnFiltered -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 @@ -465,18 +464,6 @@ class FusedAPIImpl @Inject constructor( return gPlayAPIRepository.getSearchSuggestions(query, authData) } - suspend fun fetchAuthData(): Boolean { - return gPlayAPIRepository.fetchAuthData() - } - - suspend fun fetchAuthData(email: String, aasToken: String): AuthData? { - return gPlayAPIRepository.fetchAuthData(email, aasToken) - } - - suspend fun validateAuthData(authData: AuthData): PlayResponse { - return gPlayAPIRepository.validateAuthData(authData) - } - suspend fun getOnDemandModule( authData: AuthData, packageName: String, @@ -1056,7 +1043,7 @@ class FusedAPIImpl @Inject constructor( private fun getCategoryIconName(category: FusedCategory): String { var categoryTitle = if (category.tag.getOperationalTag() - .contentEquals(AppTag.GPlay().getOperationalTag()) + .contentEquals(AppTag.GPlay().getOperationalTag()) ) category.id else category.title if (categoryTitle.contains(CATEGORY_TITLE_REPLACEABLE_CONJUNCTION)) { 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 3ea8516d29bcd1a0a54e722dc6a9b3376d7ace11..6e99241b9a16c93a61efdfbb6173266347b54c9a 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 @@ -23,7 +23,6 @@ import com.aurora.gplayapi.SearchSuggestEntry import com.aurora.gplayapi.data.models.App import com.aurora.gplayapi.data.models.AuthData import com.aurora.gplayapi.data.models.Category -import com.aurora.gplayapi.data.models.PlayResponse import com.aurora.gplayapi.data.models.StreamBundle import com.aurora.gplayapi.data.models.StreamCluster import foundation.e.apps.api.ResultSupreme @@ -39,9 +38,7 @@ import javax.inject.Inject import javax.inject.Singleton @Singleton -class FusedAPIRepository @Inject constructor( - private val fusedAPIImpl: FusedAPIImpl -) { +class FusedAPIRepository @Inject constructor(private val fusedAPIImpl: FusedAPIImpl) { var streamBundle = StreamBundle() private set @@ -93,13 +90,6 @@ class FusedAPIRepository @Inject constructor( return fusedAPIImpl.getApplicationCategoryPreference() } - suspend fun validateAuthData(authData: AuthData): PlayResponse { - if (authData.authToken.isNotEmpty() && authData.deviceInfoProvider != null) { - return fusedAPIImpl.validateAuthData(authData) - } - return PlayResponse() - } - suspend fun getApplicationDetails( packageNameList: List, authData: AuthData, @@ -165,14 +155,6 @@ class FusedAPIRepository @Inject constructor( return fusedAPIImpl.getSearchSuggestions(query, authData) } - suspend fun fetchAuthData(): Boolean { - return fusedAPIImpl.fetchAuthData() - } - - suspend fun fetchAuthData(email: String, aasToken: String): AuthData? { - return fusedAPIImpl.fetchAuthData(email, aasToken) - } - fun getSearchResults( query: String, authData: 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 a6d925734e3886464993cfbd469967134f8ae4eb..66f7ec934259e73e5984accab95f21e150e1a92a 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 @@ -18,7 +18,6 @@ package foundation.e.apps.api.gplay -import android.content.Context import androidx.lifecycle.LiveData import androidx.lifecycle.liveData import com.aurora.gplayapi.SearchSuggestEntry @@ -26,7 +25,6 @@ import com.aurora.gplayapi.data.models.App import com.aurora.gplayapi.data.models.AuthData import com.aurora.gplayapi.data.models.Category import com.aurora.gplayapi.data.models.File -import com.aurora.gplayapi.data.models.PlayResponse import com.aurora.gplayapi.data.models.SearchBundle import com.aurora.gplayapi.data.models.StreamBundle import com.aurora.gplayapi.data.models.StreamCluster @@ -37,66 +35,13 @@ import com.aurora.gplayapi.helpers.PurchaseHelper import com.aurora.gplayapi.helpers.SearchHelper import com.aurora.gplayapi.helpers.StreamHelper import com.aurora.gplayapi.helpers.TopChartsHelper -import dagger.hilt.android.qualifiers.ApplicationContext -import foundation.e.apps.api.gplay.token.TokenRepository -import foundation.e.apps.api.gplay.utils.CustomAuthValidator import foundation.e.apps.api.gplay.utils.GPlayHttpClient -import foundation.e.apps.utils.modules.DataStoreModule import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.async import kotlinx.coroutines.supervisorScope import kotlinx.coroutines.withContext import javax.inject.Inject -class GPlayAPIImpl @Inject constructor( - @ApplicationContext private val context: Context, - private val tokenRepository: TokenRepository, - private val dataStoreModule: DataStoreModule, - 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(): Boolean = withContext(Dispatchers.IO) { - val data = async { tokenRepository.getAuthData() } - 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 - } - } - - suspend fun fetchAuthData(email: String, aasToken: String): AuthData? { - val authData = tokenRepository.getAuthData(email, aasToken) - if (authData.authToken.isNotEmpty() && authData.deviceInfoProvider != null) { - dataStoreModule.saveCredentials(authData) - return authData - } - return null - } - - suspend fun validateAuthData(authData: AuthData): PlayResponse { - var result = PlayResponse() - withContext(Dispatchers.IO) { - try { - val authValidator = CustomAuthValidator(authData).using(gPlayHttpClient) - result = authValidator.getValidityResponse() - } catch (e: Exception) { - e.printStackTrace() - throw e - } - } - return result - } +class GPlayAPIImpl @Inject constructor(private val gPlayHttpClient: GPlayHttpClient) { suspend fun getSearchSuggestions(query: String, authData: AuthData): List { val searchData = mutableListOf() 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 e0309bb85a97d66f42ed7598faa874735b07fb20..f8c735a7d7249d3dfd4d9e14af3768fc1a76c141 100644 --- a/app/src/main/java/foundation/e/apps/api/gplay/GPlayAPIRepository.kt +++ b/app/src/main/java/foundation/e/apps/api/gplay/GPlayAPIRepository.kt @@ -24,27 +24,12 @@ import com.aurora.gplayapi.data.models.App import com.aurora.gplayapi.data.models.AuthData import com.aurora.gplayapi.data.models.Category import com.aurora.gplayapi.data.models.File -import com.aurora.gplayapi.data.models.PlayResponse import com.aurora.gplayapi.data.models.StreamBundle import com.aurora.gplayapi.data.models.StreamCluster import com.aurora.gplayapi.helpers.TopChartsHelper import javax.inject.Inject -class GPlayAPIRepository @Inject constructor( - private val gPlayAPIImpl: GPlayAPIImpl -) { - - suspend fun fetchAuthData(): Boolean { - return gPlayAPIImpl.fetchAuthData() - } - - suspend fun fetchAuthData(email: String, aasToken: String): AuthData? { - return gPlayAPIImpl.fetchAuthData(email, aasToken) - } - - suspend fun validateAuthData(authData: AuthData): PlayResponse { - return gPlayAPIImpl.validateAuthData(authData) - } +class GPlayAPIRepository @Inject constructor(private val gPlayAPIImpl: GPlayAPIImpl) { suspend fun getSearchSuggestions(query: String, authData: AuthData): List { return gPlayAPIImpl.getSearchSuggestions(query, authData) diff --git a/app/src/main/java/foundation/e/apps/api/gplay/token/TokenImpl.kt b/app/src/main/java/foundation/e/apps/api/gplay/token/TokenImpl.kt deleted file mode 100644 index e9578de027f924f163300590843b87ceea2c0845..0000000000000000000000000000000000000000 --- a/app/src/main/java/foundation/e/apps/api/gplay/token/TokenImpl.kt +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Apps Quickly and easily install Android apps onto your device! - * Copyright (C) 2021 E FOUNDATION - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package foundation.e.apps.api.gplay.token - -import com.aurora.gplayapi.data.models.AuthData -import com.aurora.gplayapi.helpers.AuthHelper -import com.google.gson.Gson -import foundation.e.apps.api.gplay.utils.GPlayHttpClient -import java.util.Properties -import javax.inject.Inject - -class TokenImpl @Inject constructor( - private val nativeDeviceProperty: Properties, - private val gson: Gson, - private val gPlayHttpClient: GPlayHttpClient -) { - - companion object { - const val BASE_URL = "https://eu.gtoken.ecloud.global" - } - - fun getAuthData(): AuthData? { - val playResponse = - gPlayHttpClient.postAuth(BASE_URL, gson.toJson(nativeDeviceProperty).toByteArray()) - return if (playResponse.isSuccessful) { - gson.fromJson(String(playResponse.responseBytes), AuthData::class.java) - } else { - null - } - } - - fun getAuthData(email: String, aasToken: String): AuthData { - return AuthHelper.build(email, aasToken, nativeDeviceProperty) - } -} 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 258f1b58acdfbb6f09c1613e24b9f6f660912ff9..8b222cae76f478fe5fa80186959b522bc8eb5c68 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 @@ -21,7 +21,6 @@ package foundation.e.apps.api.gplay.utils import com.aurora.gplayapi.data.models.PlayResponse import com.aurora.gplayapi.network.IHttpClient -import foundation.e.apps.utils.modules.CommonUtilsModule.timeoutDurationInMillis import okhttp3.Cache import okhttp3.Headers.Companion.toHeaders import okhttp3.HttpUrl @@ -36,7 +35,6 @@ import timber.log.Timber import java.io.IOException import java.net.SocketTimeoutException import java.net.UnknownHostException -import java.util.concurrent.TimeUnit import javax.inject.Inject class GPlayHttpClient @Inject constructor( @@ -51,10 +49,6 @@ class GPlayHttpClient @Inject constructor( } private val okHttpClient = OkHttpClient().newBuilder() - .connectTimeout(timeoutDurationInMillis, TimeUnit.MILLISECONDS) - .readTimeout(timeoutDurationInMillis, TimeUnit.MILLISECONDS) - .writeTimeout(timeoutDurationInMillis, TimeUnit.MILLISECONDS) - .callTimeout(timeoutDurationInMillis, TimeUnit.MILLISECONDS) .retryOnConnectionFailure(false) .followRedirects(true) .followSslRedirects(true) @@ -161,7 +155,9 @@ class GPlayHttpClient @Inject constructor( private fun handleExceptionOnGooglePlayRequest(e: Exception): PlayResponse { Timber.e("processRequest: ${e.localizedMessage}") - return PlayResponse() + return PlayResponse().apply { + errorString = "${this@GPlayHttpClient::class.java.simpleName}: ${e.localizedMessage}" + } } private fun buildUrl(url: String, params: Map): HttpUrl { 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 b84fbca35a8e09f920b327e32a6642c2c5599cbe..fb4335c9c41e84e95366e562cc30748f2aebdab3 100644 --- a/app/src/main/java/foundation/e/apps/application/ApplicationFragment.kt +++ b/app/src/main/java/foundation/e/apps/application/ApplicationFragment.kt @@ -27,6 +27,7 @@ import android.text.format.Formatter import android.view.View import android.widget.ImageView import android.widget.RelativeLayout +import androidx.appcompat.app.AlertDialog import androidx.core.content.ContextCompat import androidx.core.graphics.BlendModeColorFilterCompat import androidx.core.graphics.BlendModeCompat @@ -38,7 +39,6 @@ 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 @@ -53,6 +53,7 @@ import foundation.e.apps.api.fused.data.FusedApp import foundation.e.apps.application.model.ApplicationScreenshotsRVAdapter import foundation.e.apps.application.subFrags.ApplicationDialogFragment import foundation.e.apps.databinding.FragmentApplicationBinding +import foundation.e.apps.login.AuthObject import foundation.e.apps.manager.download.data.DownloadProgress import foundation.e.apps.manager.pkg.PkgManagerModule import foundation.e.apps.utils.enums.Origin @@ -60,6 +61,7 @@ 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.enums.isInitialized +import foundation.e.apps.utils.exceptions.GPlayLoginException import foundation.e.apps.utils.modules.CommonUtilsModule.LIST_OF_NULL import foundation.e.apps.utils.modules.PWAManagerModule import foundation.e.apps.utils.parentFragment.TimeoutFragment @@ -130,15 +132,15 @@ class ApplicationFragment : TimeoutFragment(R.layout.fragment_application) { super.onViewCreated(view, savedInstanceState) _binding = FragmentApplicationBinding.bind(view) - /* - * Explanation of double observers in HomeFragment.kt - */ + setupListening() - mainActivityViewModel.internetConnection.observe(viewLifecycleOwner) { - refreshDataOrRefreshToken(mainActivityViewModel) + authObjects.observe(viewLifecycleOwner) { + if (it == null) return@observe + loadData(it) } - mainActivityViewModel.authData.observe(viewLifecycleOwner) { - refreshDataOrRefreshToken(mainActivityViewModel) + + applicationViewModel.exceptionsLiveData.observe(viewLifecycleOwner) { + handleExceptionsCommon(it) } setupToolbar(view) @@ -160,23 +162,20 @@ class ApplicationFragment : TimeoutFragment(R.layout.fragment_application) { resultPair: Pair, ) { if (resultPair.second != ResultStatus.OK) { - onTimeout() return } /* - * 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 - */ + * 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() - isDetailsLoaded = true if (applicationViewModel.appStatus.value == null) { applicationViewModel.appStatus.value = it.status @@ -360,46 +359,40 @@ class ApplicationFragment : TimeoutFragment(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) { + override fun loadData(authObjectList: List) { if (isDetailsLoaded) return /* Show the loading bar. */ showLoadingUI() /* Remove trailing slash (if present) that can become part of the packageName */ val packageName = args.packageName.run { if (endsWith('/')) dropLast(1) else this } - if (isFdroidDeepLink) { - applicationViewModel.getCleanapkAppDetails(packageName) - } else { - applicationViewModel.getApplicationDetails( - args.id, - packageName, - authData, - origin - ) + + applicationViewModel.loadData(args.id, packageName, origin, isFdroidDeepLink, authObjectList) { + clearAndRestartGPlayLogin() + true } } + override fun onTimeout( + exception: Exception, + predefinedDialog: AlertDialog.Builder + ): AlertDialog.Builder? { + return predefinedDialog + } + + override fun onSignInError( + exception: GPlayLoginException, + predefinedDialog: AlertDialog.Builder + ): AlertDialog.Builder? { + return predefinedDialog + } + + override fun onDataLoadError( + exception: Exception, + predefinedDialog: AlertDialog.Builder + ): AlertDialog.Builder? { + return predefinedDialog + } + private fun observeDownloadStatus(view: View) { applicationViewModel.appStatus.observe(viewLifecycleOwner) { status -> val installButton = binding.downloadInclude.installButton @@ -492,11 +485,7 @@ class ApplicationFragment : TimeoutFragment(R.layout.fragment_application) { view: View ) { installButton.setOnClickListener { - val errorMsg = when ( - User.valueOf( - mainActivityViewModel.userType.value ?: User.UNAVAILABLE.name - ) - ) { + val errorMsg = when (mainActivityViewModel.getUser()) { User.ANONYMOUS, User.UNAVAILABLE -> getString(R.string.install_blocked_anonymous) User.GOOGLE -> getString(R.string.install_blocked_google) @@ -730,12 +719,12 @@ class ApplicationFragment : TimeoutFragment(R.layout.fragment_application) { } } - private fun showLoadingUI() { + override fun showLoadingUI() { binding.applicationLayout.visibility = View.GONE binding.progressBar.visibility = View.VISIBLE } - private fun stopLoadingUI() { + override fun stopLoadingUI() { binding.applicationLayout.visibility = View.VISIBLE binding.progressBar.visibility = View.GONE } 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 794904192fa4527382bd138474219e54551b3a78..886bcadbb33bbb22829e69cfa4f63a5df3795712 100644 --- a/app/src/main/java/foundation/e/apps/application/ApplicationViewModel.kt +++ b/app/src/main/java/foundation/e/apps/application/ApplicationViewModel.kt @@ -19,7 +19,6 @@ package foundation.e.apps.application import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.aurora.gplayapi.data.models.AuthData import com.aurora.gplayapi.exceptions.ApiException @@ -27,6 +26,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel import foundation.e.apps.R import foundation.e.apps.api.fused.FusedAPIRepository import foundation.e.apps.api.fused.data.FusedApp +import foundation.e.apps.login.AuthObject import foundation.e.apps.manager.database.fusedDownload.FusedDownload import foundation.e.apps.manager.download.data.DownloadProgress import foundation.e.apps.manager.download.data.DownloadProgressLD @@ -34,6 +34,9 @@ import foundation.e.apps.manager.fused.FusedManagerRepository 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.exceptions.CleanApkException +import foundation.e.apps.utils.exceptions.GPlayException +import foundation.e.apps.utils.parentFragment.LoadingViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import javax.inject.Inject @@ -43,7 +46,7 @@ class ApplicationViewModel @Inject constructor( downloadProgressLD: DownloadProgressLD, private val fusedAPIRepository: FusedAPIRepository, private val fusedManagerRepository: FusedManagerRepository, -) : ViewModel() { +) : LoadingViewModel() { val fusedApp: MutableLiveData> = MutableLiveData() val appStatus: MutableLiveData = MutableLiveData() @@ -51,17 +54,66 @@ class ApplicationViewModel @Inject constructor( private val _errorMessageLiveData: MutableLiveData = MutableLiveData() val errorMessageLiveData: MutableLiveData = _errorMessageLiveData + fun loadData( + id: String, + packageName: String, + origin: Origin, + isFdroidLink: Boolean, + authObjectList: List, + retryBlock: (failedObjects: List) -> Boolean, + ) { + + if (isFdroidLink) { + getCleanapkAppDetails(packageName) + return + } + + val gPlayObj = authObjectList.find { it is AuthObject.GPlayAuth } + + /* + * If user is viewing only open source apps, auth object list will not have + * GPlayAuth, it will only have CleanApkAuth. + */ + if (gPlayObj == null && origin == Origin.GPLAY) { + _errorMessageLiveData.postValue(R.string.gplay_data_for_oss) + return + } + + super.onLoadData(authObjectList, { successAuthList, _ -> + + successAuthList.find { it is AuthObject.GPlayAuth }?.run { + getApplicationDetails(id, packageName, result.data!! as AuthData, origin) + return@onLoadData + } + + successAuthList.find { it is AuthObject.CleanApk }?.run { + getApplicationDetails(id, packageName, AuthData("", ""), origin) + return@onLoadData + } + }, retryBlock) + } + fun getApplicationDetails(id: String, packageName: String, authData: AuthData, origin: Origin) { viewModelScope.launch(Dispatchers.IO) { try { - fusedApp.postValue( + val appData = fusedAPIRepository.getApplicationDetails( id, packageName, authData, origin ) - ) + fusedApp.postValue(appData) + + if (appData.second != ResultStatus.OK) { + val exception = + if (authData.aasToken.isNotBlank() || authData.authToken.isNotBlank()) + GPlayException(appData.second == ResultStatus.TIMEOUT, "Data load error") + else CleanApkException(appData.second == ResultStatus.TIMEOUT, "Data load error") + + exceptionsList.add(exception) + exceptionsLiveData.postValue(exceptionsList) + } } catch (e: ApiException.AppNotFound) { _errorMessageLiveData.postValue(R.string.app_not_found) } catch (e: Exception) { 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 534d0b49ae4afdb8deeffdd34046399502675452..6209dbde8ce79f6f8d5984f57d85bfca6c2b1fd0 100644 --- a/app/src/main/java/foundation/e/apps/applicationlist/ApplicationListFragment.kt +++ b/app/src/main/java/foundation/e/apps/applicationlist/ApplicationListFragment.kt @@ -21,6 +21,7 @@ package foundation.e.apps.applicationlist import android.os.Bundle import android.view.View import android.widget.ImageView +import androidx.appcompat.app.AlertDialog import androidx.fragment.app.activityViewModels import androidx.fragment.app.viewModels import androidx.lifecycle.lifecycleScope @@ -29,7 +30,6 @@ 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 @@ -41,9 +41,11 @@ import foundation.e.apps.api.fused.FusedAPIInterface import foundation.e.apps.api.fused.data.FusedApp import foundation.e.apps.application.subFrags.ApplicationDialogFragment import foundation.e.apps.databinding.FragmentApplicationListBinding +import foundation.e.apps.login.AuthObject import foundation.e.apps.manager.download.data.DownloadProgress import foundation.e.apps.manager.pkg.PkgManagerModule import foundation.e.apps.utils.enums.Status +import foundation.e.apps.utils.exceptions.GPlayLoginException import foundation.e.apps.utils.modules.PWAManagerModule import foundation.e.apps.utils.parentFragment.TimeoutFragment import kotlinx.coroutines.launch @@ -81,15 +83,15 @@ class ApplicationListFragment : setupRecyclerView(view) observeAppListLiveData() - /* - * Explanation of double observers in HomeFragment.kt - */ + setupListening() - mainActivityViewModel.internetConnection.observe(viewLifecycleOwner) { - refreshDataOrRefreshToken(mainActivityViewModel) + authObjects.observe(viewLifecycleOwner) { + if (it == null) return@observe + loadData(it) } - mainActivityViewModel.authData.observe(viewLifecycleOwner) { - refreshDataOrRefreshToken(mainActivityViewModel) + + viewModel.exceptionsLiveData.observe(viewLifecycleOwner) { + handleExceptionsCommon(it) } } @@ -121,11 +123,6 @@ class ApplicationListFragment : mainActivityViewModel.updateStatusOfFusedApps(it, list) adapter.setData(it) } - - /* - * Done in one line, so that on Ctrl+click on appListLiveData, - * we can see that it is being updated here. - */ } } @@ -139,18 +136,17 @@ class ApplicationListFragment : super.onResume() if (listAdapter.currentList.isNotEmpty() && viewModel.hasAnyAppInstallStatusChanged(listAdapter.currentList)) { - mainActivityViewModel.authData.value?.let { + /*mainActivityViewModel.authData.value?.let { refreshData(it) - } + }*/ + repostAuthObjects() } } private fun observeAppListLiveData() { viewModel.appListLiveData.observe(viewLifecycleOwner) { stopLoadingUI() - if (!it.isSuccess()) { - onTimeout() - } else { + if (it.isSuccess()) { if (!isFusedAppsUpdated(it)) { return@observe } @@ -230,31 +226,7 @@ class ApplicationListFragment : currentList ) - 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) { - - /* - * Code moved from onResume() - */ + override fun loadData(authObjectList: List) { /* * If details are once loaded, do not load details again, @@ -267,12 +239,10 @@ class ApplicationListFragment : * Issue: https://gitlab.e.foundation/e/os/backlog/-/issues/478 */ showLoadingUI() - viewModel.getList( - args.category, - args.browseUrl, - authData, - args.source - ) + viewModel.loadData(args.category, args.browseUrl, args.source, authObjectList) { + clearAndRestartGPlayLogin() + true + } if (args.source != "Open Source" && args.source != "PWA") { /* @@ -283,7 +253,10 @@ class ApplicationListFragment : override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) { super.onScrollStateChanged(recyclerView, newState) if (!recyclerView.canScrollVertically(1)) { - viewModel.loadMore(authData, args.browseUrl) + viewModel.loadMore( + authObjectList.find { it is AuthObject.GPlayAuth }, + args.browseUrl + ) } } }) @@ -297,20 +270,44 @@ class ApplicationListFragment : binding.recyclerView.adapter.apply { if (this is ApplicationListRVAdapter) { onPlaceHolderShow = { - viewModel.loadMore(authData, args.browseUrl) + viewModel.loadMore( + authObjectList.find { it is AuthObject.GPlayAuth }, + args.browseUrl + ) } } } } } - private fun showLoadingUI() { + override fun onTimeout( + exception: Exception, + predefinedDialog: AlertDialog.Builder + ): AlertDialog.Builder? { + return predefinedDialog + } + + override fun onSignInError( + exception: GPlayLoginException, + predefinedDialog: AlertDialog.Builder + ): AlertDialog.Builder? { + return predefinedDialog + } + + override fun onDataLoadError( + exception: Exception, + predefinedDialog: AlertDialog.Builder + ): AlertDialog.Builder? { + return predefinedDialog + } + + override fun showLoadingUI() { binding.shimmerLayout.startShimmer() binding.shimmerLayout.visibility = View.VISIBLE binding.recyclerView.visibility = View.GONE } - private fun stopLoadingUI() { + override fun stopLoadingUI() { binding.shimmerLayout.stopShimmer() binding.shimmerLayout.visibility = View.GONE binding.recyclerView.visibility = View.VISIBLE 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 106d47379f48ffe1101ffffdcd488519adb4d406..4fe45743905407dd0335a31bb409529526267e68 100644 --- a/app/src/main/java/foundation/e/apps/applicationlist/ApplicationListViewModel.kt +++ b/app/src/main/java/foundation/e/apps/applicationlist/ApplicationListViewModel.kt @@ -19,13 +19,16 @@ package foundation.e.apps.applicationlist import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.aurora.gplayapi.data.models.AuthData import dagger.hilt.android.lifecycle.HiltViewModel import foundation.e.apps.api.ResultSupreme import foundation.e.apps.api.fused.FusedAPIRepository import foundation.e.apps.api.fused.data.FusedApp +import foundation.e.apps.login.AuthObject +import foundation.e.apps.utils.exceptions.CleanApkException +import foundation.e.apps.utils.exceptions.GPlayException +import foundation.e.apps.utils.parentFragment.LoadingViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import javax.inject.Inject @@ -33,21 +36,52 @@ import javax.inject.Inject @HiltViewModel class ApplicationListViewModel @Inject constructor( private val fusedAPIRepository: FusedAPIRepository -) : ViewModel() { +) : LoadingViewModel() { val appListLiveData: MutableLiveData>> = MutableLiveData() var isLoading = false + fun loadData( + category: String, + browseUrl: String, + source: String, + authObjectList: List, + retryBlock: (failedObjects: List) -> Boolean, + ) { + super.onLoadData(authObjectList, { successAuthList, _ -> + + successAuthList.find { it is AuthObject.GPlayAuth }?.run { + getList(category, browseUrl, result.data!! as AuthData, source) + return@onLoadData + } + + successAuthList.find { it is AuthObject.CleanApk }?.run { + getList(category, browseUrl, AuthData("", ""), source) + return@onLoadData + } + }, retryBlock) + } + fun getList(category: String, browseUrl: String, authData: AuthData, source: String) { if (isLoading) { return } viewModelScope.launch(Dispatchers.IO) { isLoading = true - fusedAPIRepository.getAppList(category, browseUrl, authData, source).apply { + val result = fusedAPIRepository.getAppList(category, browseUrl, authData, source).apply { isLoading = false - appListLiveData.postValue(this) + } + appListLiveData.postValue(result) + + if (!result.isSuccess()) { + val exception = + if (authData.aasToken.isNotBlank() || authData.authToken.isNotBlank()) + GPlayException(result.isTimeout(), "Data load error") + else CleanApkException(result.isTimeout(), "Data load error") + + exceptionsList.add(exception) + exceptionsLiveData.postValue(exceptionsList) } } } @@ -62,9 +96,16 @@ class ApplicationListViewModel @Inject constructor( return fusedAPIRepository.isAnyFusedAppUpdated(newFusedApps, oldFusedApps) } - fun loadMore(authData: AuthData, browseUrl: String) { + fun loadMore(gPlayAuth: AuthObject?, browseUrl: String) { viewModelScope.launch { - if (isLoading) { + + val authData: AuthData? = when { + gPlayAuth !is AuthObject.GPlayAuth -> null + !gPlayAuth.result.isSuccess() -> null + else -> gPlayAuth.result.data!! + } + + if (isLoading || authData == null) { return@launch } @@ -91,7 +132,7 @@ class ApplicationListViewModel @Inject constructor( * for the same data. */ if (result.first.isSuccess() && !result.second && fusedAPIRepository.canLoadMore()) { - loadMore(authData, browseUrl) + loadMore(gPlayAuth, browseUrl) } } } 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 0335553b1a9fec7e0dc4a209b4a338ad05c6be5b..07497a782c24b47466508aff1f9050b777ca38ec 100644 --- a/app/src/main/java/foundation/e/apps/categories/AppsFragment.kt +++ b/app/src/main/java/foundation/e/apps/categories/AppsFragment.kt @@ -20,17 +20,18 @@ package foundation.e.apps.categories import android.os.Bundle import android.view.View +import androidx.appcompat.app.AlertDialog 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.login.AuthObject +import foundation.e.apps.utils.exceptions.GPlayLoginException import foundation.e.apps.utils.parentFragment.TimeoutFragment @AndroidEntryPoint @@ -45,21 +46,16 @@ class AppsFragment : TimeoutFragment(R.layout.fragment_apps) { super.onViewCreated(view, savedInstanceState) _binding = FragmentAppsBinding.bind(view) - /* - * Explanation of double observers in HomeFragment.kt - */ + setupListening() - mainActivityViewModel.internetConnection.observe(viewLifecycleOwner) { - refreshDataOrRefreshToken(mainActivityViewModel) - } - mainActivityViewModel.authData.observe(viewLifecycleOwner) { - refreshDataOrRefreshToken(mainActivityViewModel) + authObjects.observe(viewLifecycleOwner) { + if (it == null) return@observe + loadData(it) } - /* - * Code regarding is just moved outside the observers. - * Issue: https://gitlab.e.foundation/e/backlog/-/issues/5413 - */ + categoriesViewModel.exceptionsLiveData.observe(viewLifecycleOwner) { + handleExceptionsCommon(it) + } val categoriesRVAdapter = CategoriesRVAdapter() val recyclerView = binding.recyclerView @@ -73,47 +69,44 @@ class AppsFragment : TimeoutFragment(R.layout.fragment_apps) { 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 loadData(authObjectList: List) { + categoriesViewModel.loadData(Category.Type.APPLICATION, authObjectList) { + clearAndRestartGPlayLogin() + true } } - override fun refreshData(authData: AuthData) { - showLoadingUI() - categoriesViewModel.getCategoriesList( - Category.Type.APPLICATION, - authData - ) + override fun onTimeout( + exception: Exception, + predefinedDialog: AlertDialog.Builder + ): AlertDialog.Builder? { + return predefinedDialog + } + + override fun onSignInError( + exception: GPlayLoginException, + predefinedDialog: AlertDialog.Builder + ): AlertDialog.Builder? { + return predefinedDialog + } + + override fun onDataLoadError( + exception: Exception, + predefinedDialog: AlertDialog.Builder + ): AlertDialog.Builder? { + return predefinedDialog } - private fun showLoadingUI() { + override fun showLoadingUI() { binding.shimmerLayout.startShimmer() binding.shimmerLayout.visibility = View.VISIBLE binding.recyclerView.visibility = View.GONE } - private fun stopLoadingUI() { + override fun stopLoadingUI() { binding.shimmerLayout.stopShimmer() binding.shimmerLayout.visibility = View.GONE binding.recyclerView.visibility = View.VISIBLE @@ -121,7 +114,6 @@ class AppsFragment : TimeoutFragment(R.layout.fragment_apps) { 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 3eb07a1d81f05ce0509314e401e7408fcc3d5fd0..59e94943538673006824dbf3a85234ad5edc0c1a 100644 --- a/app/src/main/java/foundation/e/apps/categories/CategoriesFragment.kt +++ b/app/src/main/java/foundation/e/apps/categories/CategoriesFragment.kt @@ -20,17 +20,15 @@ package foundation.e.apps.categories import android.os.Bundle import android.view.View -import com.aurora.gplayapi.data.models.AuthData +import androidx.fragment.app.Fragment 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 -import timber.log.Timber @AndroidEntryPoint -class CategoriesFragment : TimeoutFragment(R.layout.fragment_categories) { +class CategoriesFragment : Fragment(R.layout.fragment_categories) { private var _binding: FragmentCategoriesBinding? = null private val binding get() = _binding!! @@ -55,25 +53,4 @@ class CategoriesFragment : TimeoutFragment(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) { - Timber.d("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 ec72f9150b308b3c46fabdf755f2ff95a350d32c..13456df3c577b58c62a059ac0ac37c4fd10d045f 100644 --- a/app/src/main/java/foundation/e/apps/categories/CategoriesViewModel.kt +++ b/app/src/main/java/foundation/e/apps/categories/CategoriesViewModel.kt @@ -19,28 +19,61 @@ package foundation.e.apps.categories import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.aurora.gplayapi.data.models.AuthData import com.aurora.gplayapi.data.models.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.login.AuthObject import foundation.e.apps.utils.enums.ResultStatus +import foundation.e.apps.utils.exceptions.CleanApkException +import foundation.e.apps.utils.exceptions.GPlayException +import foundation.e.apps.utils.parentFragment.LoadingViewModel import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel class CategoriesViewModel @Inject constructor( private val fusedAPIRepository: FusedAPIRepository -) : ViewModel() { +) : LoadingViewModel() { val categoriesList: MutableLiveData, String, ResultStatus>> = MutableLiveData() + fun loadData( + type: Category.Type, + authObjectList: List, + retryBlock: (failedObjects: List) -> Boolean, + ) { + super.onLoadData(authObjectList, { successAuthList, _ -> + + successAuthList.find { it is AuthObject.GPlayAuth }?.run { + getCategoriesList(type, result.data!! as AuthData) + return@onLoadData + } + + successAuthList.find { it is AuthObject.CleanApk }?.run { + getCategoriesList(type, AuthData("", "")) + return@onLoadData + } + }, retryBlock) + } + fun getCategoriesList(type: Category.Type, authData: AuthData) { viewModelScope.launch { - categoriesList.postValue(fusedAPIRepository.getCategoriesList(type, authData)) + val categoriesData = fusedAPIRepository.getCategoriesList(type, authData) + categoriesList.postValue(categoriesData) + + if (categoriesData.third != ResultStatus.OK) { + val exception = + if (authData.aasToken.isNotBlank() || authData.authToken.isNotBlank()) + GPlayException(categoriesData.third == ResultStatus.TIMEOUT, "Data load error") + else CleanApkException(categoriesData.third == ResultStatus.TIMEOUT, "Data load error") + + exceptionsList.add(exception) + exceptionsLiveData.postValue(exceptionsList) + } } } 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 1094519744559f0633b6132f7bca100c2cfa09d1..f5d9fe62bc4a5cdc1d93fbb5417a0bc7a28038be 100644 --- a/app/src/main/java/foundation/e/apps/categories/GamesFragment.kt +++ b/app/src/main/java/foundation/e/apps/categories/GamesFragment.kt @@ -20,17 +20,18 @@ package foundation.e.apps.categories import android.os.Bundle import android.view.View +import androidx.appcompat.app.AlertDialog 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.login.AuthObject +import foundation.e.apps.utils.exceptions.GPlayLoginException import foundation.e.apps.utils.parentFragment.TimeoutFragment @AndroidEntryPoint @@ -45,15 +46,15 @@ class GamesFragment : TimeoutFragment(R.layout.fragment_games) { super.onViewCreated(view, savedInstanceState) _binding = FragmentGamesBinding.bind(view) - /* - * Explanation of double observers in HomeFragment.kt - */ + setupListening() - mainActivityViewModel.internetConnection.observe(viewLifecycleOwner) { - refreshDataOrRefreshToken(mainActivityViewModel) + authObjects.observe(viewLifecycleOwner) { + if (it == null) return@observe + loadData(it) } - mainActivityViewModel.authData.observe(viewLifecycleOwner) { - refreshDataOrRefreshToken(mainActivityViewModel) + + categoriesViewModel.exceptionsLiveData.observe(viewLifecycleOwner) { + handleExceptionsCommon(it) } val categoriesRVAdapter = CategoriesRVAdapter() @@ -68,47 +69,44 @@ class GamesFragment : TimeoutFragment(R.layout.fragment_games) { 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 loadData(authObjectList: List) { + categoriesViewModel.loadData(Category.Type.GAME, authObjectList) { + clearAndRestartGPlayLogin() + true } } - override fun refreshData(authData: AuthData) { - showLoadingUI() - categoriesViewModel.getCategoriesList( - Category.Type.GAME, - authData - ) + override fun onTimeout( + exception: Exception, + predefinedDialog: AlertDialog.Builder + ): AlertDialog.Builder? { + return predefinedDialog + } + + override fun onSignInError( + exception: GPlayLoginException, + predefinedDialog: AlertDialog.Builder + ): AlertDialog.Builder? { + return predefinedDialog + } + + override fun onDataLoadError( + exception: Exception, + predefinedDialog: AlertDialog.Builder + ): AlertDialog.Builder? { + return predefinedDialog } - private fun showLoadingUI() { + override fun showLoadingUI() { binding.shimmerLayout.startShimmer() binding.shimmerLayout.visibility = View.VISIBLE binding.recyclerView.visibility = View.GONE } - private fun stopLoadingUI() { + override fun stopLoadingUI() { binding.shimmerLayout.stopShimmer() binding.shimmerLayout.visibility = View.GONE binding.recyclerView.visibility = View.VISIBLE @@ -116,7 +114,6 @@ class GamesFragment : TimeoutFragment(R.layout.fragment_games) { override fun onResume() { super.onResume() - resetTimeoutDialogLock() binding.shimmerLayout.startShimmer() } diff --git a/app/src/main/java/foundation/e/apps/di/LoginModule.kt b/app/src/main/java/foundation/e/apps/di/LoginModule.kt new file mode 100644 index 0000000000000000000000000000000000000000..559d7e84da66af3c455937cdbf1b4f05fe01cadd --- /dev/null +++ b/app/src/main/java/foundation/e/apps/di/LoginModule.kt @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2019-2022 E FOUNDATION + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * 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.di + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import foundation.e.apps.login.LoginSourceCleanApk +import foundation.e.apps.login.LoginSourceGPlay +import foundation.e.apps.login.LoginSourceInterface + +@InstallIn(SingletonComponent::class) +@Module +object LoginModule { + @Provides + fun providesLoginSources( + gPlay: LoginSourceGPlay, + cleanApk: LoginSourceCleanApk, + ): List { + return listOf(gPlay, cleanApk) + } +} 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 7866ead064c31a7069626c511fadaf55802e238f..31dc533114dc8ff96b272a9b4d15db9b41145a7e 100644 --- a/app/src/main/java/foundation/e/apps/home/HomeFragment.kt +++ b/app/src/main/java/foundation/e/apps/home/HomeFragment.kt @@ -21,19 +21,18 @@ package foundation.e.apps.home import android.os.Bundle import android.view.View import android.widget.ImageView +import androidx.appcompat.app.AlertDialog import androidx.fragment.app.activityViewModels import androidx.fragment.app.viewModels import androidx.lifecycle.lifecycleScope import androidx.navigation.findNavController import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView -import com.aurora.gplayapi.data.models.AuthData import dagger.hilt.android.AndroidEntryPoint import foundation.e.apps.AppInfoFetchViewModel import foundation.e.apps.AppProgressViewModel import foundation.e.apps.MainActivityViewModel import foundation.e.apps.R -import foundation.e.apps.api.fused.FusedAPIImpl import foundation.e.apps.api.fused.FusedAPIInterface import foundation.e.apps.api.fused.data.FusedApp import foundation.e.apps.api.fused.data.FusedHome @@ -41,11 +40,13 @@ import foundation.e.apps.application.subFrags.ApplicationDialogFragment import foundation.e.apps.databinding.FragmentHomeBinding import foundation.e.apps.home.model.HomeChildRVAdapter import foundation.e.apps.home.model.HomeParentRVAdapter +import foundation.e.apps.login.AuthObject 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.exceptions.GPlayException +import foundation.e.apps.utils.exceptions.GPlayLoginException import foundation.e.apps.utils.modules.CommonUtilsModule.safeNavigate import foundation.e.apps.utils.modules.PWAManagerModule import foundation.e.apps.utils.parentFragment.TimeoutFragment @@ -78,14 +79,6 @@ class HomeFragment : TimeoutFragment(R.layout.fragment_home), FusedAPIInterface super.onViewCreated(view, savedInstanceState) _binding = FragmentHomeBinding.bind(view) - mainActivityViewModel.userType.observe(viewLifecycleOwner) { user -> - if (user.isNullOrEmpty() || User.valueOf(user) == User.UNAVAILABLE) { - mainActivityViewModel.tocStatus.observe(viewLifecycleOwner) { tosAccepted -> - onTosAccepted(tosAccepted) - } - } - } - loadHomePageData() homeParentRVAdapter = initHomeParentRVAdapter() @@ -102,7 +95,6 @@ class HomeFragment : TimeoutFragment(R.layout.fragment_home), FusedAPIInterface homeViewModel.homeScreenData.observe(viewLifecycleOwner) { stopLoadingUI() if (it.second != ResultStatus.OK) { - onTimeout() return@observe } @@ -110,7 +102,6 @@ class HomeFragment : TimeoutFragment(R.layout.fragment_home), FusedAPIInterface return@observe } - dismissTimeoutDialog() homeParentRVAdapter?.setData(it.first) } } @@ -119,7 +110,7 @@ class HomeFragment : TimeoutFragment(R.layout.fragment_home), FusedAPIInterface this, pkgManagerModule, pwaManagerModule, - User.valueOf(mainActivityViewModel.userType.value ?: User.UNAVAILABLE.name), + mainActivityViewModel.getUser(), mainActivityViewModel, appInfoFetchViewModel, viewLifecycleOwner ) { fusedApp -> if (!mainActivityViewModel.shouldShowPaidAppsSnackBar(fusedApp)) { @@ -128,49 +119,15 @@ class HomeFragment : TimeoutFragment(R.layout.fragment_home), FusedAPIInterface } private fun loadHomePageData() { - /* - * 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 - */ + setupListening() - mainActivityViewModel.internetConnection.observe(viewLifecycleOwner) { - refreshDataOrRefreshToken(mainActivityViewModel) + authObjects.observe(viewLifecycleOwner) { + if (it == null) return@observe + loadData(it) } - mainActivityViewModel.authData.observe(viewLifecycleOwner) { - refreshDataOrRefreshToken(mainActivityViewModel) + + homeViewModel.exceptionsLiveData.observe(viewLifecycleOwner) { + handleExceptionsCommon(it) } } @@ -196,49 +153,61 @@ class HomeFragment : TimeoutFragment(R.layout.fragment_home), FusedAPIInterface homeParentRVAdapter?.currentList as List ) - 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 = { + override fun onTimeout( + exception: Exception, + predefinedDialog: AlertDialog.Builder + ): AlertDialog.Builder? { + return predefinedDialog.apply { + if (exception is GPlayException) { + setMessage(R.string.timeout_desc_gplay) + setNegativeButton(R.string.open_settings) { _, _ -> openSettings() - }, - allowCancel = false, - ) + } + } else { + setMessage(R.string.timeout_desc_cleanapk) + } + setCancelable(false) } } - override fun refreshData(authData: AuthData) { - showLoadingUI() - homeViewModel.getHomeScreenData(authData) + override fun onSignInError( + exception: GPlayLoginException, + predefinedDialog: AlertDialog.Builder + ): AlertDialog.Builder? { + return predefinedDialog.apply { + setNegativeButton(R.string.open_settings) { _, _ -> + openSettings() + } + } } - private fun showLoadingUI() { + override fun onDataLoadError( + exception: Exception, + predefinedDialog: AlertDialog.Builder + ): AlertDialog.Builder? { + return predefinedDialog.apply { + if (exception is GPlayException) { + setNegativeButton(R.string.open_settings) { _, _ -> + openSettings() + } + } + } + } + + override fun loadData(authObjectList: List) { + homeViewModel.loadData(authObjectList) { _ -> + clearAndRestartGPlayLogin() + true + } + } + + override fun showLoadingUI() { binding.shimmerLayout.startShimmer() binding.shimmerLayout.visibility = View.VISIBLE binding.parentRV.visibility = View.GONE } - private fun stopLoadingUI() { + override fun stopLoadingUI() { binding.shimmerLayout.stopShimmer() binding.shimmerLayout.visibility = View.GONE binding.parentRV.visibility = View.VISIBLE @@ -297,16 +266,13 @@ class HomeFragment : TimeoutFragment(R.layout.fragment_home), FusedAPIInterface override fun onResume() { super.onResume() - resetTimeoutDialogLock() binding.shimmerLayout.startShimmer() appProgressViewModel.downloadProgress.observe(viewLifecycleOwner) { updateProgressOfDownloadingAppItemViews(homeParentRVAdapter, it) } if (homeViewModel.isAnyAppInstallStatusChanged(homeParentRVAdapter?.currentList)) { - mainActivityViewModel.authData.value?.let { - refreshData(it) - } + repostAuthObjects() } } 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 2e19e78b55aa590f108ffe43044e82910e89dcfa..596d07673a161a19bf07cd0542e9be4301a1b7fa 100644 --- a/app/src/main/java/foundation/e/apps/home/HomeViewModel.kt +++ b/app/src/main/java/foundation/e/apps/home/HomeViewModel.kt @@ -19,21 +19,24 @@ package foundation.e.apps.home import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope 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.api.fused.data.FusedHome +import foundation.e.apps.login.AuthObject import foundation.e.apps.utils.enums.ResultStatus +import foundation.e.apps.utils.exceptions.CleanApkException +import foundation.e.apps.utils.exceptions.GPlayException +import foundation.e.apps.utils.parentFragment.LoadingViewModel import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel class HomeViewModel @Inject constructor( private val fusedAPIRepository: FusedAPIRepository, -) : ViewModel() { +) : LoadingViewModel() { /* * Hold list of applications, as well as application source type. @@ -43,10 +46,38 @@ class HomeViewModel @Inject constructor( */ var homeScreenData: MutableLiveData, ResultStatus>> = MutableLiveData() + fun loadData( + authObjectList: List, + retryBlock: (failedObjects: List) -> Boolean, + ) { + super.onLoadData(authObjectList, { successAuthList, _ -> + + successAuthList.find { it is AuthObject.GPlayAuth }?.run { + getHomeScreenData(result.data!! as AuthData) + return@onLoadData + } + + successAuthList.find { it is AuthObject.CleanApk }?.run { + getHomeScreenData(AuthData("", "")) + return@onLoadData + } + }, retryBlock) + } + fun getHomeScreenData(authData: AuthData) { viewModelScope.launch { val screenData = fusedAPIRepository.getHomeScreenData(authData) homeScreenData.postValue(screenData) + + if (screenData.second != ResultStatus.OK) { + val exception = + if (authData.aasToken.isNotBlank() || authData.authToken.isNotBlank()) + GPlayException(screenData.second == ResultStatus.TIMEOUT, "Data load error") + else CleanApkException(screenData.second == ResultStatus.TIMEOUT, "Data load error") + + exceptionsList.add(exception) + exceptionsLiveData.postValue(exceptionsList) + } } } diff --git a/app/src/main/java/foundation/e/apps/login/AuthObject.kt b/app/src/main/java/foundation/e/apps/login/AuthObject.kt new file mode 100644 index 0000000000000000000000000000000000000000..84567b197a7be1664ecb387c257164b8079c30e5 --- /dev/null +++ b/app/src/main/java/foundation/e/apps/login/AuthObject.kt @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2019-2022 E FOUNDATION + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * 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.login + +import com.aurora.gplayapi.data.models.AuthData +import foundation.e.apps.api.ResultSupreme +import foundation.e.apps.login.AuthObject.GPlayAuth +import foundation.e.apps.utils.enums.User + +/** + * Auth objects define which sources data is to be loaded from, for each source, also provides + * a means of authentication to get the data. + * Example, for Google Play, we have [GPlayAuth] which contains [AuthData]. + * For CleanApk, we don't need any authentication. + * + * In future, combination of sources is possible. + * + * https://gitlab.e.foundation/e/backlog/-/issues/5680 + */ +sealed class AuthObject { + + abstract val result: ResultSupreme<*> + + class GPlayAuth(override val result: ResultSupreme, val user: User) : AuthObject() + class CleanApk(override val result: ResultSupreme, val user: User) : AuthObject() + // Add more auth types here +} diff --git a/app/src/main/java/foundation/e/apps/login/LoginCommon.kt b/app/src/main/java/foundation/e/apps/login/LoginCommon.kt new file mode 100644 index 0000000000000000000000000000000000000000..1f0e82aa5ddd8b7a598dacc33cf464cc092d7997 --- /dev/null +++ b/app/src/main/java/foundation/e/apps/login/LoginCommon.kt @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2019-2022 E FOUNDATION + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * 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.login + +import foundation.e.apps.utils.enums.User +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Contains common function for first login, logout, get which type of authentication / source + * to be used etc... + * + * https://gitlab.e.foundation/e/backlog/-/issues/5680 + */ +@Singleton +class LoginCommon @Inject constructor( + private val loginDataStore: LoginDataStore, +) { + suspend fun saveUserType(user: User) { + loginDataStore.saveUserType(user) + } + + suspend fun saveGoogleLogin(email: String, oauth: String) { + loginDataStore.saveGoogleLogin(email, oauth) + } + + suspend fun logout() { + loginDataStore.destroyCredentials() + loginDataStore.clearUserType() + } +} diff --git a/app/src/main/java/foundation/e/apps/login/LoginDataStore.kt b/app/src/main/java/foundation/e/apps/login/LoginDataStore.kt new file mode 100644 index 0000000000000000000000000000000000000000..6a10235289365d8b4d3e86ad01afe553eb2d1465 --- /dev/null +++ b/app/src/main/java/foundation/e/apps/login/LoginDataStore.kt @@ -0,0 +1,180 @@ +/* + * Copyright (C) 2019-2022 E FOUNDATION + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * 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.login + +import android.content.Context +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.preference.PreferenceManager +import com.aurora.gplayapi.data.models.AuthData +import com.google.gson.Gson +import dagger.hilt.android.qualifiers.ApplicationContext +import foundation.e.apps.utils.Constants.PREFERENCE_SHOW_FOSS +import foundation.e.apps.utils.Constants.PREFERENCE_SHOW_GPLAY +import foundation.e.apps.utils.Constants.PREFERENCE_SHOW_PWA +import foundation.e.apps.utils.enums.User +import foundation.e.apps.utils.modules.DataStoreModule.Companion.dataStore +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.runBlocking +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class LoginDataStore @Inject constructor( + @ApplicationContext + private val context: Context, + private val gson: Gson +) { + + private val preferenceManager = PreferenceManager.getDefaultSharedPreferences(context) + + private val AUTHDATA = stringPreferencesKey("authData") + private val EMAIL = stringPreferencesKey("email") + private val OAUTHTOKEN = stringPreferencesKey("oauthtoken") + private val AASTOKEN = stringPreferencesKey("aasToken") + private val USERTYPE = stringPreferencesKey("userType") + + /* + * Difference between OAUTHTOKEN and AASTOKEN: + * + * These two are used only for Google login, not for Anonymous login. + * OAuthToken is obtained from the Google Login web page, from the cookies. + * This OAuthToken is then used by AC2DMTask in GPlayAPIImpl class + * to generate AasToken. + * + * To get Google Play Store data, we need to create an AuthData instance. + * For Google user, this can only be done using AasToken, not OAuthToken. + * + * Very important: AasToken can be generated only ONCE from one OAuthToken. + * We cannot get AasToken again from the same OAuthToken. Thus it is + * important to safely store the AasToken to regenerate AuthData if needed. + * If AasToken is not stored, user has to logout and login again. + */ + + val authData = context.dataStore.data.map { it[AUTHDATA] ?: "" } + val emailData = context.dataStore.data.map { it[EMAIL] ?: "" } + val aasToken = context.dataStore.data.map { it[AASTOKEN] ?: "" } + val oauthToken = context.dataStore.data.map { it[OAUTHTOKEN] ?: "" } + val userType = context.dataStore.data.map { it[USERTYPE] ?: "" } + + // Setters + + suspend fun saveAuthData(authData: AuthData) { + context.dataStore.edit { + it[AUTHDATA] = gson.toJson(authData) + } + } + + suspend fun saveUserType(user: User) { + context.dataStore.edit { + it[USERTYPE] = user.name + } + } + + suspend fun saveGoogleLogin(email: String, token: String) { + context.dataStore.edit { + it[EMAIL] = email + it[OAUTHTOKEN] = token + } + } + + suspend fun saveAasToken(aasToken: String) { + context.dataStore.edit { + it[AASTOKEN] = aasToken + } + } + + // Getters + + fun getAuthData(): String { + return runBlocking { + authData.first() + } + } + + /** + * Get the [User] type stored in the data store. + * In case nothing is stored, returns [User.UNAVAILABLE]. + * + * No need to wrap this function in try-catch block. + * + * Issue: https://gitlab.e.foundation/e/backlog/-/issues/5680 + */ + fun getUserType(): User { + return runBlocking { + userType.first().run { + val userStrings = User.values().map { it.name } + if (this !in userStrings) User.UNAVAILABLE + else User.valueOf(this) + } + } + } + + fun getEmail(): String { + return runBlocking { + emailData.first() + } + } + + fun getOAuthToken(): String { + return runBlocking { + oauthToken.first() + } + } + + fun getAASToken(): String { + return runBlocking { + aasToken.first() + } + } + + fun isOpenSourceSelected() = preferenceManager.getBoolean(PREFERENCE_SHOW_FOSS, true) + fun isPWASelected() = preferenceManager.getBoolean(PREFERENCE_SHOW_PWA, true) + fun isGplaySelected() = preferenceManager.getBoolean(PREFERENCE_SHOW_GPLAY, true) + + // Clear data + + /** + * Destroy auth credentials if they are no longer valid. + * + * Modification for issue: https://gitlab.e.foundation/e/backlog/-/issues/5168 + * Previously this method would also remove [USERTYPE]. + * To clear this value, call [clearUserType]. + */ + suspend fun destroyCredentials() { + context.dataStore.edit { + it.remove(AUTHDATA) + it.remove(EMAIL) + it.remove(OAUTHTOKEN) + it.remove(AASTOKEN) + } + } + + suspend fun clearAuthData() { + context.dataStore.edit { + it.remove(AUTHDATA) + } + } + + suspend fun clearUserType() { + context.dataStore.edit { + it.remove(USERTYPE) + } + } +} diff --git a/app/src/main/java/foundation/e/apps/login/LoginSourceCleanApk.kt b/app/src/main/java/foundation/e/apps/login/LoginSourceCleanApk.kt new file mode 100644 index 0000000000000000000000000000000000000000..2b8f4eadc37fdd213601d43fbe82a15c798c8022 --- /dev/null +++ b/app/src/main/java/foundation/e/apps/login/LoginSourceCleanApk.kt @@ -0,0 +1,55 @@ +/* + * Copyright (C) 2019-2022 E FOUNDATION + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * 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.login + +import foundation.e.apps.api.ResultSupreme +import foundation.e.apps.utils.enums.User +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Just a dummy class for CleanApk, as it requires no authentication. + * https://gitlab.e.foundation/e/backlog/-/issues/5680 + */ +@Singleton +class LoginSourceCleanApk @Inject constructor( + val loginDataStore: LoginDataStore, +) : LoginSourceInterface { + + private val user: User + get() = loginDataStore.getUserType() + + override fun isActive(): Boolean { + if (user == User.UNAVAILABLE) { + /* + * UNAVAILABLE user means first login is not completed. + */ + return false + } + return loginDataStore.isOpenSourceSelected() || loginDataStore.isPWASelected() + } + + override suspend fun getAuthObject(): AuthObject.CleanApk { + return AuthObject.CleanApk( + ResultSupreme.Success(Unit), + user, + ) + } + + override suspend fun clearSavedAuth() {} +} diff --git a/app/src/main/java/foundation/e/apps/login/LoginSourceGPlay.kt b/app/src/main/java/foundation/e/apps/login/LoginSourceGPlay.kt new file mode 100644 index 0000000000000000000000000000000000000000..0126beaea3be0b8a9672b5b4d93096a87bd1f7b9 --- /dev/null +++ b/app/src/main/java/foundation/e/apps/login/LoginSourceGPlay.kt @@ -0,0 +1,232 @@ +/* + * Copyright (C) 2019-2022 E FOUNDATION + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * 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.login + +import com.aurora.gplayapi.data.models.AuthData +import com.google.gson.Gson +import foundation.e.apps.api.ResultSupreme +import foundation.e.apps.login.api.GPlayApiFactory +import foundation.e.apps.login.api.GPlayLoginInterface +import foundation.e.apps.login.api.GoogleLoginApi +import foundation.e.apps.login.api.LoginApiRepository +import foundation.e.apps.utils.enums.User +import foundation.e.apps.utils.exceptions.GPlayValidationException +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Class to get GPlay auth data. Call [getAuthObject] to get an already saved auth data + * or to fetch a new one for first use. Handles auth validation internally. + * + * https://gitlab.e.foundation/e/backlog/-/issues/5680 + */ +@Singleton +class LoginSourceGPlay @Inject constructor( + private val gson: Gson, + private val loginDataStore: LoginDataStore, +) : LoginSourceInterface { + + @Inject + lateinit var gPlayApiFactory: GPlayApiFactory + + private val user: User + get() = loginDataStore.getUserType() + + private val gPlayLoginInterface: GPlayLoginInterface + get() = gPlayApiFactory.getGPlayApi(user) + + private val loginApiRepository: LoginApiRepository + get() = LoginApiRepository(gPlayLoginInterface, user) + + override fun isActive(): Boolean { + if (user == User.UNAVAILABLE) { + /* + * UNAVAILABLE user means first login is not completed. + */ + return false + } + return loginDataStore.isGplaySelected() + } + + /** + * Main entry point to get GPlay auth data. + */ + override suspend fun getAuthObject(): AuthObject.GPlayAuth { + val savedAuth = getSavedAuthData() + + val authData = ( + savedAuth ?: run { + // if no saved data, then generate new auth data. + generateAuthData().let { + if (it.isSuccess()) it.data!! + else return AuthObject.GPlayAuth(it, user) + } + } + ) + + // validate authData and save it if nothing is saved (first time use.) + validateAuthData(authData).run { + if (isSuccess() && savedAuth == null) { + saveAuthData(authData) + } + return AuthObject.GPlayAuth(this, user) + } + } + + override suspend fun clearSavedAuth() { + loginDataStore.clearAuthData() + } + + /** + * Get authData stored as JSON and convert to AuthData class. + * Returns null if nothing is saved. + */ + private fun getSavedAuthData(): AuthData? { + val authJson = loginDataStore.getAuthData() + return if (authJson.isBlank()) null + else try { + gson.fromJson(authJson, AuthData::class.java) + } catch (e: Exception) { + e.printStackTrace() + null + } + } + + private suspend fun saveAuthData(authData: AuthData) { + loginDataStore.saveAuthData(authData) + } + + /** + * Generate new AuthData based on the user type. + */ + private suspend fun generateAuthData(): ResultSupreme { + return when (loginDataStore.getUserType()) { + User.ANONYMOUS -> getAuthData() + User.GOOGLE -> { + getAuthData( + loginDataStore.getEmail(), + loginDataStore.getOAuthToken(), + loginDataStore.getAASToken() + ) + } + else -> ResultSupreme.Error("User type not ANONYMOUS or GOOGLE") + } + } + + /** + * Aurora OSS GPlay API complains of missing headers sometimes. + * Converting [authData] to Json and back to [AuthData] fixed it. + */ + private fun formattedAuthData(authData: AuthData): AuthData { + val localAuthDataJson = gson.toJson(authData) + return gson.fromJson(localAuthDataJson, AuthData::class.java) + } + + /** + * Get AuthData for ANONYMOUS mode. + */ + private suspend fun getAuthData(): ResultSupreme { + return loginApiRepository.fetchAuthData("", "").run { + if (isSuccess()) ResultSupreme.Success(formattedAuthData(this.data!!)) + else this + } + } + + /** + * Get AuthData for GOOGLE login mode. + */ + private suspend fun getAuthData( + email: String, + oauthToken: String, + aasToken: String, + ): ResultSupreme { + + /* + * If aasToken is not blank, means it was stored successfully from a previous Google login. + * Use it to fetch auth data. + */ + if (aasToken.isNotBlank()) { + return loginApiRepository.fetchAuthData(email, aasToken) + } + + /* + * If aasToken is not yet saved / made, fetch it from email and oauthToken. + */ + val aasTokenResponse = loginApiRepository.getAasToken( + gPlayLoginInterface as GoogleLoginApi, + email, + oauthToken + ) + + /* + * If fetch was unsuccessful, return blank auth data. + * We replicate from the response, so that it will carry on any error message if present + * in the aasTokenResponse. + */ + if (!aasTokenResponse.isSuccess()) { + return ResultSupreme.replicate(aasTokenResponse, null) + } + + val aasTokenFetched = aasTokenResponse.data ?: "" + + if (aasTokenFetched.isBlank()) { + return ResultSupreme.Error("Fetched AAS Token is blank") + } + + /* + * Finally save the aasToken and create auth data. + */ + loginDataStore.saveAasToken(aasTokenFetched) + return loginApiRepository.fetchAuthData(email, aasTokenFetched) + } + + /** + * Check if a given [AuthData] from Google login or Anonymous login is valid or not. + * If valid, return the AuthData wrapped in [ResultSupreme], else return null, + * with error message. + */ + private suspend fun validateAuthData( + authData: AuthData, + ): ResultSupreme { + + val formattedAuthData = formattedAuthData(authData) + + val validityResponse = loginApiRepository.login(formattedAuthData) + + /* + * Send the email as payload. This is sent to ecloud in case of failure. + * See MainActivityViewModel.uploadFaultyTokenToEcloud. + */ + validityResponse.otherPayload = formattedAuthData.email + + val playResponse = validityResponse.data + return if (validityResponse.isSuccess() && playResponse?.code == 200 && playResponse.isSuccessful) { + ResultSupreme.Success(authData) + } else { + val message = + "Validating AuthData failed.\n\n" + + "Success: ${playResponse?.isSuccessful}" + + (validityResponse.exception?.let { "\n${it.message}" } ?: "") + + ResultSupreme.Error( + message, + GPlayValidationException(message, user, playResponse?.code ?: -1) + ) + } + } +} diff --git a/app/src/main/java/foundation/e/apps/login/LoginSourceInterface.kt b/app/src/main/java/foundation/e/apps/login/LoginSourceInterface.kt new file mode 100644 index 0000000000000000000000000000000000000000..79250e87e52c8cca0cdbdffffca4debd6b4668e9 --- /dev/null +++ b/app/src/main/java/foundation/e/apps/login/LoginSourceInterface.kt @@ -0,0 +1,30 @@ +/* + * Copyright (C) 2019-2022 E FOUNDATION + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * 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.login + +/** + * Interface that defines what methods a login source must define. + * Login sources (can also be called - data sources): Google Play, CleanApk. + * + * https://gitlab.e.foundation/e/backlog/-/issues/5680 + */ +interface LoginSourceInterface { + suspend fun getAuthObject(): AuthObject + suspend fun clearSavedAuth() + fun isActive(): Boolean +} diff --git a/app/src/main/java/foundation/e/apps/login/LoginSourceRepository.kt b/app/src/main/java/foundation/e/apps/login/LoginSourceRepository.kt new file mode 100644 index 0000000000000000000000000000000000000000..e403c8ba170322d45acda9f1ca6c9f3fd1bd2ed6 --- /dev/null +++ b/app/src/main/java/foundation/e/apps/login/LoginSourceRepository.kt @@ -0,0 +1,57 @@ +/* + * Copyright (C) 2019-2022 E FOUNDATION + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * 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.login + +import foundation.e.apps.utils.enums.User +import javax.inject.Inject +import javax.inject.Singleton + +@JvmSuppressWildcards +@Singleton +class LoginSourceRepository @Inject constructor( + private val loginCommon: LoginCommon, + private val sources: List, +) { + + suspend fun getAuthObjects(clearAuthTypes: List = listOf()): List { + + val authObjectsLocal = ArrayList() + + for (source in sources) { + if (!source.isActive()) continue + if (source::class.java.simpleName in clearAuthTypes) { + source.clearSavedAuth() + } + authObjectsLocal.add(source.getAuthObject()) + } + + return authObjectsLocal + } + + suspend fun saveUserType(user: User) { + loginCommon.saveUserType(user) + } + + suspend fun saveGoogleLogin(email: String, oauth: String) { + loginCommon.saveGoogleLogin(email, oauth) + } + + suspend fun logout() { + loginCommon.logout() + } +} diff --git a/app/src/main/java/foundation/e/apps/login/LoginViewModel.kt b/app/src/main/java/foundation/e/apps/login/LoginViewModel.kt new file mode 100644 index 0000000000000000000000000000000000000000..e8ead132770c17f6ce28ca27da6d4d4d476d9d33 --- /dev/null +++ b/app/src/main/java/foundation/e/apps/login/LoginViewModel.kt @@ -0,0 +1,96 @@ +/* + * Copyright (C) 2019-2022 E FOUNDATION + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * 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.login + +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import foundation.e.apps.utils.enums.User +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * ViewModel to handle all login related operations. + * Use it as shared view model across all fragments. + */ +@HiltViewModel +class LoginViewModel @Inject constructor( + private val loginSourceRepository: LoginSourceRepository, +) : ViewModel() { + + /** + * List of authentication objects which will determine from where to load the data. + * (i.e. GPlay, CleanApk) + * + * Allow null as initial value. + * This prevents showing login screen before TOS is accepted. + * This also allows to set null immediately when sources are changed in Settings, + * and when [startLoginFlow] is called, fragments wait till actual AuthObjects + * are loaded instead of null. + */ + val authObjects: MutableLiveData?> = MutableLiveData(null) + + /** + * Main point of starting of entire authentication process. + */ + fun startLoginFlow(clearList: List = listOf()) { + viewModelScope.launch { + val authObjectsLocal = loginSourceRepository.getAuthObjects(clearList) + authObjects.postValue(authObjectsLocal) + } + } + + /** + * Call this to use ANONYMOUS mode. + * This method is called only for the first time when logging in the user. + * @param onUserSaved Code to execute once user is saved. Ends the sign in screen. + */ + fun initialAnonymousLogin(onUserSaved: () -> Unit) { + viewModelScope.launch { + loginSourceRepository.saveUserType(User.ANONYMOUS) + onUserSaved() + startLoginFlow() + } + } + + /** + * Call this to use GOOGLE login mode. + * This method is called only for the first time when logging in the user. + * @param onUserSaved Code to execute once email, oauthToken and user is saved. + * Ends the sign in screen. + */ + fun initialGoogleLogin(email: String, oauthToken: String, onUserSaved: () -> Unit) { + viewModelScope.launch { + loginSourceRepository.saveGoogleLogin(email, oauthToken) + loginSourceRepository.saveUserType(User.GOOGLE) + onUserSaved() + startLoginFlow() + } + } + + /** + * Clears all saved data and logs out the user to the sign in screen. + */ + fun logout() { + viewModelScope.launch { + loginSourceRepository.logout() + authObjects.postValue(listOf()) + } + } +} diff --git a/app/src/main/java/foundation/e/apps/login/api/AnonymousLoginApi.kt b/app/src/main/java/foundation/e/apps/login/api/AnonymousLoginApi.kt new file mode 100644 index 0000000000000000000000000000000000000000..dcc7f99b8bcc9edaa19052b1a73193a3208eb9f1 --- /dev/null +++ b/app/src/main/java/foundation/e/apps/login/api/AnonymousLoginApi.kt @@ -0,0 +1,81 @@ +/* + * Copyright (C) 2019-2022 E FOUNDATION + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * 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.login.api + +import com.aurora.gplayapi.data.models.AuthData +import com.aurora.gplayapi.data.models.PlayResponse +import com.google.gson.Gson +import foundation.e.apps.BuildConfig +import foundation.e.apps.api.gplay.utils.CustomAuthValidator +import foundation.e.apps.api.gplay.utils.GPlayHttpClient +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.util.Properties + +class AnonymousLoginApi( + private val gPlayHttpClient: GPlayHttpClient, + private val nativeDeviceProperty: Properties, + private val gson: Gson, +) : GPlayLoginInterface { + + private val tokenUrl: String + get() { + return if (BuildConfig.DEBUG) "https://eu.gtoken.eeo.one" + else "https://eu.gtoken.ecloud.global" + } + + /** + * Fetches AuthData for Anonymous login. + * @param email Keep it blank (""). + * @param aasToken Keep it blank (""). + */ + override suspend fun fetchAuthData(email: String, aasToken: String): AuthData? { + var authData: AuthData? = null + withContext(Dispatchers.IO) { + val response = + gPlayHttpClient.postAuth(tokenUrl, gson.toJson(nativeDeviceProperty).toByteArray()) + if (response.code != 200 || !response.isSuccessful) { + throw Exception("Error fetching Anonymous credentials: ${response.errorString}") + } else { + authData = gson.fromJson( + String(response.responseBytes), + AuthData::class.java + ) + } + } + return authData + } + + /** + * Check if an AuthData is valid. Returns a [PlayResponse]. + * Check [PlayResponse.isSuccessful] to see if the validation was successful. + */ + override suspend fun login(authData: AuthData): PlayResponse { + var result = PlayResponse() + withContext(Dispatchers.IO) { + try { + val authValidator = CustomAuthValidator(authData).using(gPlayHttpClient) + result = authValidator.getValidityResponse() + } catch (e: Exception) { + e.printStackTrace() + throw e + } + } + return result + } +} diff --git a/app/src/main/java/foundation/e/apps/login/api/GPlayApiFactory.kt b/app/src/main/java/foundation/e/apps/login/api/GPlayApiFactory.kt new file mode 100644 index 0000000000000000000000000000000000000000..a910d525dd39ab0804274a8c1ba368a03a03d14b --- /dev/null +++ b/app/src/main/java/foundation/e/apps/login/api/GPlayApiFactory.kt @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2019-2022 E FOUNDATION + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * 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.login.api + +import com.google.gson.Gson +import foundation.e.apps.api.gplay.utils.AC2DMTask +import foundation.e.apps.api.gplay.utils.GPlayHttpClient +import foundation.e.apps.utils.enums.User +import java.util.Properties +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class GPlayApiFactory @Inject constructor( + private val gPlayHttpClient: GPlayHttpClient, + private val nativeDeviceProperty: Properties, + private val aC2DMTask: AC2DMTask, + private val gson: Gson, +) { + + fun getGPlayApi(user: User): GPlayLoginInterface { + return when (user) { + User.GOOGLE -> GoogleLoginApi(gPlayHttpClient, nativeDeviceProperty, aC2DMTask) + else -> AnonymousLoginApi(gPlayHttpClient, nativeDeviceProperty, gson) + } + } +} diff --git a/app/src/main/java/foundation/e/apps/api/gplay/token/TokenRepository.kt b/app/src/main/java/foundation/e/apps/login/api/GPlayLoginInterface.kt similarity index 58% rename from app/src/main/java/foundation/e/apps/api/gplay/token/TokenRepository.kt rename to app/src/main/java/foundation/e/apps/login/api/GPlayLoginInterface.kt index f5c7c9a355f75c28d75f3f43b4e25f36c69065e0..24b212bbcb7aa20a5c33554668eedf039b3ded91 100644 --- a/app/src/main/java/foundation/e/apps/api/gplay/token/TokenRepository.kt +++ b/app/src/main/java/foundation/e/apps/login/api/GPlayLoginInterface.kt @@ -1,35 +1,26 @@ -/* - * Apps Quickly and easily install Android apps onto your device! - * Copyright (C) 2021 E FOUNDATION - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package foundation.e.apps.api.gplay.token - -import com.aurora.gplayapi.data.models.AuthData -import javax.inject.Inject - -class TokenRepository @Inject constructor( - private val tokenImpl: TokenImpl -) { - - fun getAuthData(): AuthData? { - return tokenImpl.getAuthData() - } - - fun getAuthData(email: String, aasToken: String): AuthData { - return tokenImpl.getAuthData(email, aasToken) - } -} +/* + * Copyright (C) 2019-2022 E FOUNDATION + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * 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.login.api + +import com.aurora.gplayapi.data.models.AuthData +import com.aurora.gplayapi.data.models.PlayResponse + +interface GPlayLoginInterface { + suspend fun login(authData: AuthData): PlayResponse + suspend fun fetchAuthData(email: String, aasToken: String): AuthData? +} diff --git a/app/src/main/java/foundation/e/apps/login/api/GoogleLoginApi.kt b/app/src/main/java/foundation/e/apps/login/api/GoogleLoginApi.kt new file mode 100644 index 0000000000000000000000000000000000000000..47c7bb19adc31fc3f572dd97c4f11b88e38cbcf3 --- /dev/null +++ b/app/src/main/java/foundation/e/apps/login/api/GoogleLoginApi.kt @@ -0,0 +1,82 @@ +/* + * Copyright (C) 2019-2022 E FOUNDATION + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * 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.login.api + +import com.aurora.gplayapi.data.models.AuthData +import com.aurora.gplayapi.data.models.PlayResponse +import com.aurora.gplayapi.helpers.AuthHelper +import foundation.e.apps.api.gplay.utils.AC2DMTask +import foundation.e.apps.api.gplay.utils.CustomAuthValidator +import foundation.e.apps.api.gplay.utils.GPlayHttpClient +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.util.Properties + +class GoogleLoginApi( + private val gPlayHttpClient: GPlayHttpClient, + private val nativeDeviceProperty: Properties, + private val aC2DMTask: AC2DMTask, +) : GPlayLoginInterface { + + /** + * Get PlayResponse for AC2DM Map. This allows us to get an error message too. + * + * An aasToken is extracted from this map. This is passed to [fetchAuthData] + * to generate AuthData. This token is very important as it cannot be regenerated, + * hence it must be saved for future use. + * + * Issue: https://gitlab.e.foundation/e/backlog/-/issues/5680 + */ + suspend fun getAC2DMResponse(email: String, oauthToken: String): PlayResponse { + var response = PlayResponse() + withContext(Dispatchers.IO) { + response = aC2DMTask.getAC2DMResponse(email, oauthToken) + } + return response + } + + /** + * Convert email and AASToken to AuthData class. + * Issue: https://gitlab.e.foundation/e/backlog/-/issues/5680 + */ + override suspend fun fetchAuthData(email: String, aasToken: String): AuthData? { + var authData: AuthData? = null + withContext(Dispatchers.IO) { + authData = AuthHelper.build(email, aasToken, nativeDeviceProperty) + } + return authData + } + + /** + * Check if an AuthData is valid. Returns a [PlayResponse]. + * Check [PlayResponse.isSuccessful] to see if the validation was successful. + */ + override suspend fun login(authData: AuthData): PlayResponse { + var result = PlayResponse() + withContext(Dispatchers.IO) { + try { + val authValidator = CustomAuthValidator(authData).using(gPlayHttpClient) + result = authValidator.getValidityResponse() + } catch (e: Exception) { + e.printStackTrace() + throw e + } + } + return result + } +} diff --git a/app/src/main/java/foundation/e/apps/login/api/LoginApiRepository.kt b/app/src/main/java/foundation/e/apps/login/api/LoginApiRepository.kt new file mode 100644 index 0000000000000000000000000000000000000000..44dcd0c7d74c6e76afa622f94a0052b196751b20 --- /dev/null +++ b/app/src/main/java/foundation/e/apps/login/api/LoginApiRepository.kt @@ -0,0 +1,161 @@ +/* + * Copyright (C) 2019-2022 E FOUNDATION + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * 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.login.api + +import com.aurora.gplayapi.data.models.AuthData +import com.aurora.gplayapi.data.models.PlayResponse +import foundation.e.apps.api.ResultSupreme +import foundation.e.apps.api.gplay.utils.AC2DMUtil +import foundation.e.apps.utils.Constants.timeoutDurationInMillis +import foundation.e.apps.utils.enums.User +import foundation.e.apps.utils.exceptions.GPlayLoginException +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.withTimeout + +/** + * Call methods of [GoogleLoginApi] and [AnonymousLoginApi] from here. + * + * Dependency Injection via hilt is not possible, + * we need to manually check login type, create an instance of either [GoogleLoginApi] + * or [AnonymousLoginApi] and pass it to [gPlayLoginInterface]. + * + * Issue: https://gitlab.e.foundation/e/backlog/-/issues/5680 + */ +class LoginApiRepository constructor( + private val gPlayLoginInterface: GPlayLoginInterface, + private val user: User, +) { + + /** + * Gets the auth data from instance of [GPlayLoginInterface]. + * Applicable for both Google and Anonymous login. + * + * Issue: https://gitlab.e.foundation/e/backlog/-/issues/5680 + * @param email Email address for Google login. Blank for Anonymous login. + * @param aasToken For Google login - Access token obtained from [getAasToken] function, + * else blank for Anonymous login. + */ + suspend fun fetchAuthData(email: String, aasToken: String): ResultSupreme { + val result = runCodeBlockWithTimeout({ + gPlayLoginInterface.fetchAuthData(email, aasToken) + }) + return result.apply { + this.exception = when (result) { + is ResultSupreme.Timeout -> GPlayLoginException(true, "GPlay API timeout", user) + is ResultSupreme.Error -> GPlayLoginException(false, result.message, user) + else -> null + } + } + } + + /** + * Get AuthData validity of in the form of PlayResponse. + * Advantage of not using a simple boolean is we get error message and + * network code of the request inside PlayResponse object. + * + * Applicable for both Google and Anonymous login. + * + * Issue: https://gitlab.e.foundation/e/backlog/-/issues/5680 + */ + suspend fun login(authData: AuthData): ResultSupreme { + var response = PlayResponse() + val result = runCodeBlockWithTimeout({ + response = gPlayLoginInterface.login(authData) + if (response.code != 200) { + throw Exception("Validation network code: ${response.code}") + } + response + }) + return ResultSupreme.replicate(result, response).apply { + this.exception = when (result) { + is ResultSupreme.Timeout -> GPlayLoginException(true, "GPlay API timeout", user) + is ResultSupreme.Error -> GPlayLoginException(false, result.message, user) + else -> null + } + } + } + + /** + * Gets email and oauthToken from Google login, finds the AASToken from AC2DM response + * and returns it. This token is then used to fetch AuthData from [fetchAuthData]. + * + * Do note that for a given oauthToken, it has been observed that AASToken can + * only be generated once. So this token must be saved for future use. + * + * Issue: https://gitlab.e.foundation/e/backlog/-/issues/5680 + * + * @param googleLoginApi An instance of [GoogleLoginApi] must be passed, this method + * cannot work on [gPlayLoginInterface] as it is a common interface for both Google and Anonymous + * login, but this method is only for Google login. + */ + suspend fun getAasToken( + googleLoginApi: GoogleLoginApi, + email: String, + oauthToken: String + ): ResultSupreme { + val result = runCodeBlockWithTimeout({ + var aasToken = "" + val response = googleLoginApi.getAC2DMResponse(email, oauthToken) + var error = response.errorString + if (response.isSuccessful) { + val responseMap = AC2DMUtil.parseResponse(String(response.responseBytes)) + aasToken = responseMap["Token"] ?: "" + if (aasToken.isBlank() && error.isBlank()) { + error = "AASToken not found in map." + } + } + /* + * Default value of PlayResponse.errorString is "No Error". + * https://gitlab.com/AuroraOSS/gplayapi/-/blob/master/src/main/java/com/aurora/gplayapi/data/models/PlayResponse.kt + */ + if (error != "No Error") { + throw Exception(error) + } + aasToken + }) + return result.apply { + this.exception = when (result) { + is ResultSupreme.Timeout -> GPlayLoginException(true, "GPlay API timeout", User.GOOGLE) + is ResultSupreme.Error -> GPlayLoginException(false, result.message, User.GOOGLE) + else -> null + } + } + } + + /** + * Utility method to run a specified code block in a fixed amount of time. + */ + private suspend fun runCodeBlockWithTimeout( + block: suspend () -> T, + timeoutBlock: (() -> T?)? = null, + exceptionBlock: (() -> T?)? = null, + ): ResultSupreme { + return try { + withTimeout(timeoutDurationInMillis) { + return@withTimeout ResultSupreme.Success(block()) + } + } catch (e: TimeoutCancellationException) { + ResultSupreme.Timeout(timeoutBlock?.invoke()).apply { + message = e.message ?: "" + } + } catch (e: Exception) { + e.printStackTrace() + ResultSupreme.Error(exceptionBlock?.invoke(), message = e.message ?: "") + } + } +} 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 35de693ce48cd94c1652670479a94a8cff74f1a0..90a95147d25d6c3e88d748dfca5dd1038d6e296b 100644 --- a/app/src/main/java/foundation/e/apps/search/SearchFragment.kt +++ b/app/src/main/java/foundation/e/apps/search/SearchFragment.kt @@ -27,6 +27,7 @@ import android.view.View import android.view.inputmethod.InputMethodManager import android.widget.ImageView import android.widget.LinearLayout +import androidx.appcompat.app.AlertDialog import androidx.appcompat.widget.SearchView import androidx.core.view.isVisible import androidx.cursoradapter.widget.CursorAdapter @@ -38,7 +39,6 @@ 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 @@ -52,9 +52,11 @@ import foundation.e.apps.api.fused.data.FusedApp import foundation.e.apps.application.subFrags.ApplicationDialogFragment import foundation.e.apps.applicationlist.ApplicationListRVAdapter import foundation.e.apps.databinding.FragmentSearchBinding +import foundation.e.apps.login.AuthObject import foundation.e.apps.manager.download.data.DownloadProgress import foundation.e.apps.manager.pkg.PkgManagerModule import foundation.e.apps.utils.enums.Status +import foundation.e.apps.utils.exceptions.GPlayLoginException import foundation.e.apps.utils.modules.PWAManagerModule import foundation.e.apps.utils.parentFragment.TimeoutFragment import kotlinx.coroutines.launch @@ -92,7 +94,7 @@ class SearchFragment : private var noAppsFoundLayout: LinearLayout? = null /* - * Store the string from onQueryTextSubmit() and access it from refreshData() + * Store the string from onQueryTextSubmit() and access it from loadData() * Issue: https://gitlab.e.foundation/e/backlog/-/issues/5413 */ private var searchText = "" @@ -114,6 +116,17 @@ class SearchFragment : val listAdapter = setupSearchResult(view) observeSearchResult(listAdapter) + + setupListening() + + authObjects.observe(viewLifecycleOwner) { + if (it == null) return@observe + loadData(it) + } + + searchViewModel.exceptionsLiveData.observe(viewLifecycleOwner) { + handleExceptionsCommon(it) + } } private fun observeSearchResult(listAdapter: ApplicationListRVAdapter?) { @@ -129,13 +142,6 @@ class SearchFragment : } observeScrollOfSearchResult(listAdapter) - if (searchText.isNotBlank() && !it.isSuccess()) { - /* - * If blank check is not performed then timeout dialog keeps - * popping up whenever search tab is opened. - */ - onTimeout() - } } } @@ -253,39 +259,42 @@ class SearchFragment : } } - override fun onTimeout() { - if (!isTimeoutDialogDisplayed()) { - binding.loadingProgressBar.isVisible = false - 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 onTimeout( + exception: Exception, + predefinedDialog: AlertDialog.Builder + ): AlertDialog.Builder? { + return predefinedDialog + } + + override fun onDataLoadError( + exception: Exception, + predefinedDialog: AlertDialog.Builder + ): AlertDialog.Builder? { + return predefinedDialog } - override fun refreshData(authData: AuthData) { + override fun onSignInError( + exception: GPlayLoginException, + predefinedDialog: AlertDialog.Builder + ): AlertDialog.Builder? { + return predefinedDialog + } + + override fun loadData(authObjectList: List) { showLoadingUI() - searchViewModel.getSearchResults(searchText, authData, viewLifecycleOwner) + searchViewModel.loadData(searchText, viewLifecycleOwner, authObjectList) { + clearAndRestartGPlayLogin() + true + } } - private fun showLoadingUI() { + override fun showLoadingUI() { binding.shimmerLayout.startShimmer() binding.shimmerLayout.visibility = View.VISIBLE binding.recyclerView.visibility = View.GONE } - private fun stopLoadingUI() { + override fun stopLoadingUI() { binding.shimmerLayout.stopShimmer() binding.shimmerLayout.visibility = View.GONE binding.recyclerView.visibility = View.VISIBLE @@ -315,16 +324,13 @@ class SearchFragment : override fun onResume() { super.onResume() - resetTimeoutDialogLock() binding.shimmerLayout.startShimmer() appProgressViewModel.downloadProgress.observe(viewLifecycleOwner) { updateProgressOfInstallingApps(it) } if (shouldRefreshData()) { - mainActivityViewModel.authData.value?.let { - refreshData(it) - } + repostAuthObjects() } if (searchText.isEmpty() && (recyclerView?.adapter as ApplicationListRVAdapter).currentList.isEmpty()) { @@ -358,15 +364,15 @@ class SearchFragment : * Set the search text and call for network result. */ searchText = text - refreshDataOrRefreshToken(mainActivityViewModel) + repostAuthObjects() } return false } override fun onQueryTextChange(newText: String?): Boolean { newText?.let { text -> - mainActivityViewModel.authData.value?.let { - searchViewModel.getSearchSuggestions(text, it) + authObjects.value?.find { it is AuthObject.GPlayAuth }?.run { + searchViewModel.getSearchSuggestions(text, this as AuthObject.GPlayAuth) } } 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 1442ad98f347c9a7735219eaf1832f805308228a..02c10cca4bbd8586417826ce71319c9e82644fba 100644 --- a/app/src/main/java/foundation/e/apps/search/SearchViewModel.kt +++ b/app/src/main/java/foundation/e/apps/search/SearchViewModel.kt @@ -20,7 +20,6 @@ package foundation.e.apps.search import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.aurora.gplayapi.SearchSuggestEntry import com.aurora.gplayapi.data.models.AuthData @@ -28,6 +27,10 @@ import dagger.hilt.android.lifecycle.HiltViewModel import foundation.e.apps.api.ResultSupreme import foundation.e.apps.api.fused.FusedAPIRepository import foundation.e.apps.api.fused.data.FusedApp +import foundation.e.apps.login.AuthObject +import foundation.e.apps.utils.exceptions.CleanApkException +import foundation.e.apps.utils.exceptions.GPlayException +import foundation.e.apps.utils.parentFragment.LoadingViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import javax.inject.Inject @@ -35,18 +38,42 @@ import javax.inject.Inject @HiltViewModel class SearchViewModel @Inject constructor( private val fusedAPIRepository: FusedAPIRepository, -) : ViewModel() { +) : LoadingViewModel() { val searchSuggest: MutableLiveData?> = MutableLiveData() val searchResult: MutableLiveData, Boolean>>> = MutableLiveData() - fun getSearchSuggestions(query: String, authData: AuthData) { + fun getSearchSuggestions(query: String, gPlayAuth: AuthObject.GPlayAuth) { viewModelScope.launch(Dispatchers.IO) { - searchSuggest.postValue(fusedAPIRepository.getSearchSuggestions(query, authData)) + if (gPlayAuth.result.isSuccess()) + searchSuggest.postValue(fusedAPIRepository.getSearchSuggestions(query, gPlayAuth.result.data!!)) } } + fun loadData( + query: String, + lifecycleOwner: LifecycleOwner, + authObjectList: List, + retryBlock: (failedObjects: List) -> Boolean + ) { + + if (query.isBlank()) return + + super.onLoadData(authObjectList, { successAuthList, _ -> + + successAuthList.find { it is AuthObject.GPlayAuth }?.run { + getSearchResults(query, result.data!! as AuthData, lifecycleOwner) + return@onLoadData + } + + successAuthList.find { it is AuthObject.CleanApk }?.run { + getSearchResults(query, AuthData("", ""), lifecycleOwner) + return@onLoadData + } + }, retryBlock) + } + /* * Observe data from Fused API and publish the result in searchResult. * This allows us to show apps as they are being fetched from the network, @@ -57,6 +84,16 @@ class SearchViewModel @Inject constructor( viewModelScope.launch(Dispatchers.Main) { fusedAPIRepository.getSearchResults(query, authData).observe(lifecycleOwner) { searchResult.postValue(it) + + if (!it.isSuccess()) { + val exception = + if (authData.aasToken.isNotBlank() || authData.authToken.isNotBlank()) { + GPlayException(it.isTimeout(), "Data load error") + } else CleanApkException(it.isTimeout(), "Data load error") + + exceptionsList.add(exception) + exceptionsLiveData.postValue(exceptionsList) + } } } } diff --git a/app/src/main/java/foundation/e/apps/settings/SettingsFragment.kt b/app/src/main/java/foundation/e/apps/settings/SettingsFragment.kt index 18cbb4164307a63681eefae720f9c6d83ab311db..5c56f9b97e692c7e5bdc0d93d9f766a7d335b826 100644 --- a/app/src/main/java/foundation/e/apps/settings/SettingsFragment.kt +++ b/app/src/main/java/foundation/e/apps/settings/SettingsFragment.kt @@ -21,19 +21,16 @@ package foundation.e.apps.settings import android.content.ClipboardManager import android.content.Intent import android.os.Bundle -import android.os.Handler -import android.os.Looper import android.view.View import android.widget.Toast -import androidx.fragment.app.viewModels +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.ViewModelProvider import androidx.navigation.findNavController -import androidx.navigation.fragment.findNavController import androidx.preference.CheckBoxPreference import androidx.preference.Preference import androidx.preference.PreferenceFragmentCompat import androidx.work.ExistingPeriodicWorkPolicy import coil.load -import com.aurora.gplayapi.data.models.AuthData import com.google.gson.Gson import dagger.hilt.android.AndroidEntryPoint import foundation.e.apps.BuildConfig @@ -41,7 +38,7 @@ import foundation.e.apps.MainActivity import foundation.e.apps.MainActivityViewModel import foundation.e.apps.R import foundation.e.apps.databinding.CustomPreferenceBinding -import foundation.e.apps.setup.signin.SignInViewModel +import foundation.e.apps.login.LoginViewModel import foundation.e.apps.updates.manager.UpdatesWorkManager import foundation.e.apps.utils.enums.User import foundation.e.apps.utils.modules.CommonUtilsFunctions @@ -53,12 +50,17 @@ class SettingsFragment : PreferenceFragmentCompat() { private var _binding: CustomPreferenceBinding? = null private val binding get() = _binding!! - private val viewModel: SignInViewModel by viewModels() - private val mainActivityViewModel: MainActivityViewModel by viewModels() + private val mainActivityViewModel: MainActivityViewModel by activityViewModels() private var showAllApplications: CheckBoxPreference? = null private var showFOSSApplications: CheckBoxPreference? = null private var showPWAApplications: CheckBoxPreference? = null + val loginViewModel: LoginViewModel by lazy { + ViewModelProvider(requireActivity())[LoginViewModel::class.java] + } + + private var sourcesChangedFlag = false + @Inject lateinit var gson: Gson @@ -69,6 +71,10 @@ class SettingsFragment : PreferenceFragmentCompat() { private const val TAG = "SettingsFragment" } + private val allSourceCheckboxes by lazy { + listOf(showAllApplications, showFOSSApplications, showPWAApplications) + } + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { setPreferencesFromResource(R.xml.settings_preferences, rootKey) @@ -112,25 +118,51 @@ class SettingsFragment : PreferenceFragmentCompat() { true } } + + allSourceCheckboxes.forEach { + it?.onPreferenceChangeListener = sourceCheckboxListener + } } + /** + * Checkbox listener to prevent all checkboxes from getting unchecked. + */ + private val sourceCheckboxListener = + Preference.OnPreferenceChangeListener { preference: Preference, newValue: Any? -> + + sourcesChangedFlag = true + loginViewModel.authObjects.value = null + + val otherBoxesChecked = + allSourceCheckboxes.filter { it != preference }.any { it?.isChecked == true } + + if (newValue == false && !otherBoxesChecked) { + (preference as CheckBoxPreference).isChecked = true + Toast.makeText( + requireActivity(), + R.string.select_one_source_of_applications, + Toast.LENGTH_SHORT + ).show() + false + } else true + } + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) _binding = CustomPreferenceBinding.bind(view) super.onViewCreated(view, savedInstanceState) - mainActivityViewModel.authDataJson.observe(viewLifecycleOwner) { - val authData = gson.fromJson(it, AuthData::class.java) - viewModel.userType.observe(viewLifecycleOwner) { user -> + mainActivityViewModel.gPlayAuthData.let { authData -> + mainActivityViewModel.getUser().name.let { user -> when (user) { User.ANONYMOUS.name -> { binding.accountType.text = view.context.getString(R.string.user_anonymous) } User.GOOGLE.name -> { - if (authData != null) { + if (!authData.isAnonymous) { binding.accountType.text = authData.userProfile?.name - binding.email.text = authData.userProfile?.email + binding.email.text = mainActivityViewModel.getUserEmail() binding.avatar.load(authData.userProfile?.artwork?.url) } } @@ -143,11 +175,7 @@ class SettingsFragment : PreferenceFragmentCompat() { } binding.logout.setOnClickListener { - viewModel.saveUserType(User.UNAVAILABLE) - Toast.makeText(requireContext(), "Signing out...", Toast.LENGTH_LONG).show() - Handler(Looper.getMainLooper()).postDelayed({ - backToMainActivity() - }, 1500) + loginViewModel.logout() } } @@ -164,6 +192,9 @@ class SettingsFragment : PreferenceFragmentCompat() { } override fun onDestroyView() { + if (sourcesChangedFlag) { + loginViewModel.startLoginFlow() + } super.onDestroyView() _binding = null } diff --git a/app/src/main/java/foundation/e/apps/setup/signin/SignInFragment.kt b/app/src/main/java/foundation/e/apps/setup/signin/SignInFragment.kt index b2e354b674df9e2fd145321194f877e7a61fba64..ffd568aa0a8e3282b9f6f84c9a82287314e4c9c0 100644 --- a/app/src/main/java/foundation/e/apps/setup/signin/SignInFragment.kt +++ b/app/src/main/java/foundation/e/apps/setup/signin/SignInFragment.kt @@ -3,46 +3,36 @@ package foundation.e.apps.setup.signin import android.os.Bundle import android.view.View import androidx.fragment.app.Fragment -import androidx.fragment.app.viewModels +import androidx.lifecycle.ViewModelProvider import androidx.navigation.findNavController import dagger.hilt.android.AndroidEntryPoint import foundation.e.apps.R import foundation.e.apps.databinding.FragmentSignInBinding -import foundation.e.apps.utils.enums.User +import foundation.e.apps.login.LoginViewModel +import foundation.e.apps.utils.modules.CommonUtilsModule.safeNavigate @AndroidEntryPoint class SignInFragment : Fragment(R.layout.fragment_sign_in) { private var _binding: FragmentSignInBinding? = null private val binding get() = _binding!! - private val viewModel: SignInViewModel by viewModels() + private val viewModel: LoginViewModel by lazy { + ViewModelProvider(requireActivity())[LoginViewModel::class.java] + } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) _binding = FragmentSignInBinding.bind(view) binding.googleBT.setOnClickListener { - view.findNavController().navigate(R.id.googleSignInFragment) + view.findNavController() + .safeNavigate(R.id.signInFragment, R.id.action_signInFragment_to_googleSignInFragment) } binding.anonymousBT.setOnClickListener { - viewModel.saveUserType(User.ANONYMOUS) - } - - viewModel.userType.observe(viewLifecycleOwner) { - if (it.isNotBlank()) { - when (User.valueOf(it)) { - User.ANONYMOUS -> { - view.findNavController() - .navigate(R.id.action_signInFragment_to_homeFragment) - } - User.GOOGLE -> { - view.findNavController() - .navigate(R.id.action_signInFragment_to_homeFragment) - } - else -> { - } - } + viewModel.initialAnonymousLogin { + view.findNavController() + .safeNavigate(R.id.signInFragment, R.id.action_signInFragment_to_homeFragment) } } } diff --git a/app/src/main/java/foundation/e/apps/setup/signin/google/GoogleSignInFragment.kt b/app/src/main/java/foundation/e/apps/setup/signin/google/GoogleSignInFragment.kt index d21a536467e9210f5157e3c252e5bd9e717eeba1..8470c359c846868c7bf590d7cfd5b576f57d5228 100644 --- a/app/src/main/java/foundation/e/apps/setup/signin/google/GoogleSignInFragment.kt +++ b/app/src/main/java/foundation/e/apps/setup/signin/google/GoogleSignInFragment.kt @@ -27,21 +27,23 @@ import android.webkit.WebSettings import android.webkit.WebView import android.webkit.WebViewClient import androidx.fragment.app.Fragment -import androidx.fragment.app.viewModels +import androidx.lifecycle.ViewModelProvider import androidx.navigation.findNavController import dagger.hilt.android.AndroidEntryPoint import foundation.e.apps.R import foundation.e.apps.api.gplay.utils.AC2DMUtil import foundation.e.apps.databinding.FragmentGoogleSigninBinding -import foundation.e.apps.setup.signin.SignInViewModel -import foundation.e.apps.utils.enums.User +import foundation.e.apps.login.LoginViewModel +import foundation.e.apps.utils.modules.CommonUtilsModule.safeNavigate @AndroidEntryPoint class GoogleSignInFragment : Fragment(R.layout.fragment_google_signin) { private var _binding: FragmentGoogleSigninBinding? = null private val binding get() = _binding!! - private val viewModel: SignInViewModel by viewModels() + private val viewModel: LoginViewModel by lazy { + ViewModelProvider(requireActivity())[LoginViewModel::class.java] + } companion object { private const val EMBEDDED_SETUP_URL = @@ -53,18 +55,6 @@ class GoogleSignInFragment : Fragment(R.layout.fragment_google_signin) { super.onViewCreated(view, savedInstanceState) _binding = FragmentGoogleSigninBinding.bind(view) setupWebView() - - viewModel.userType.observe(viewLifecycleOwner) { - if (it.isNotBlank()) { - when (User.valueOf(it)) { - User.GOOGLE -> { - view.findNavController() - .navigate(R.id.action_googleSignInFragment_to_homeFragment) - } - else -> {} - } - } - } } @SuppressLint("SetJavaScriptEnabled") @@ -84,10 +74,17 @@ class GoogleSignInFragment : Fragment(R.layout.fragment_google_signin) { val cookieMap = AC2DMUtil.parseCookieString(cookies) if (cookieMap.isNotEmpty() && cookieMap[AUTH_TOKEN] != null) { val oauthToken = cookieMap[AUTH_TOKEN] ?: "" - view.evaluateJavascript("(function() { return document.getElementById('profileIdentifier').innerHTML; })();") { + view.evaluateJavascript( + "document.querySelector(\"div[data-profile-identifier]\").textContent;" + ) { val email = it.replace("\"".toRegex(), "") - viewModel.saveEmailToken(email, oauthToken) - viewModel.saveUserType(User.GOOGLE) + viewModel.initialGoogleLogin(email, oauthToken) { + view.findNavController() + .safeNavigate( + R.id.googleSignInFragment, + R.id.action_googleSignInFragment_to_homeFragment + ) + } } } } diff --git a/app/src/main/java/foundation/e/apps/setup/tos/TOSFragment.kt b/app/src/main/java/foundation/e/apps/setup/tos/TOSFragment.kt index 920a70f492e971d514fa8b965a5cbd7d80ce6a02..9a0515c1584f0b35b163c04aff866218b7d80eb3 100644 --- a/app/src/main/java/foundation/e/apps/setup/tos/TOSFragment.kt +++ b/app/src/main/java/foundation/e/apps/setup/tos/TOSFragment.kt @@ -19,10 +19,6 @@ class TOSFragment : Fragment(R.layout.fragment_tos) { private var _binding: FragmentTosBinding? = null private val binding get() = _binding!! - /* - * Fix memory leaks related to WebView. - * Issue: https://gitlab.e.foundation/e/os/backlog/-/issues/485 - */ private var webView: WebView? = null private val viewModel: TOSViewModel by viewModels() @@ -31,12 +27,8 @@ class TOSFragment : Fragment(R.layout.fragment_tos) { super.onViewCreated(view, savedInstanceState) _binding = FragmentTosBinding.bind(view) webView = binding.tosWebView - var canNavigate = false viewModel.tocStatus.observe(viewLifecycleOwner) { - if (canNavigate) { - view.findNavController().navigate(R.id.action_TOSFragment_to_signInFragment) - } if (it == true && webView != null) { binding.TOSWarning.visibility = View.GONE @@ -68,7 +60,6 @@ class TOSFragment : Fragment(R.layout.fragment_tos) { binding.agreeBT.setOnClickListener { viewModel.saveTOCStatus(true) - canNavigate = true } } 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 e773e2cc4470fc300060c0d306cbf27c90f5bf7c..06c7640e84630d49811ea57b2161d8c3b9b4cd96 100644 --- a/app/src/main/java/foundation/e/apps/updates/UpdatesFragment.kt +++ b/app/src/main/java/foundation/e/apps/updates/UpdatesFragment.kt @@ -21,6 +21,7 @@ package foundation.e.apps.updates import android.os.Bundle import android.view.View import android.widget.ImageView +import androidx.appcompat.app.AlertDialog import androidx.fragment.app.activityViewModels import androidx.fragment.app.viewModels import androidx.lifecycle.lifecycleScope @@ -30,25 +31,25 @@ import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import androidx.work.WorkInfo import androidx.work.WorkManager -import com.aurora.gplayapi.data.models.AuthData import dagger.hilt.android.AndroidEntryPoint import foundation.e.apps.AppInfoFetchViewModel import foundation.e.apps.AppProgressViewModel import foundation.e.apps.MainActivityViewModel import foundation.e.apps.PrivacyInfoViewModel import foundation.e.apps.R -import foundation.e.apps.api.fused.FusedAPIImpl import foundation.e.apps.api.fused.FusedAPIInterface import foundation.e.apps.api.fused.data.FusedApp import foundation.e.apps.application.subFrags.ApplicationDialogFragment import foundation.e.apps.applicationlist.ApplicationListRVAdapter import foundation.e.apps.databinding.FragmentUpdatesBinding +import foundation.e.apps.login.AuthObject import foundation.e.apps.manager.download.data.DownloadProgress import foundation.e.apps.manager.pkg.PkgManagerModule import foundation.e.apps.manager.workmanager.InstallWorkManager.INSTALL_WORK_NAME import foundation.e.apps.updates.manager.UpdatesWorkManager -import foundation.e.apps.utils.enums.ResultStatus import foundation.e.apps.utils.enums.Status +import foundation.e.apps.utils.exceptions.GPlayException +import foundation.e.apps.utils.exceptions.GPlayLoginException import foundation.e.apps.utils.modules.CommonUtilsModule.safeNavigate import foundation.e.apps.utils.modules.PWAManagerModule import foundation.e.apps.utils.parentFragment.TimeoutFragment @@ -85,7 +86,7 @@ class UpdatesFragment : TimeoutFragment(R.layout.fragment_updates), FusedAPIInte * Explanation of double observers in HomeFragment.kt */ - mainActivityViewModel.internetConnection.observe(viewLifecycleOwner) { + /*mainActivityViewModel.internetConnection.observe(viewLifecycleOwner) { if (!updatesViewModel.updatesList.value?.first.isNullOrEmpty()) { return@observe } @@ -93,6 +94,20 @@ class UpdatesFragment : TimeoutFragment(R.layout.fragment_updates), FusedAPIInte } mainActivityViewModel.authData.observe(viewLifecycleOwner) { refreshDataOrRefreshToken(mainActivityViewModel) + }*/ + + setupListening() + + authObjects.observe(viewLifecycleOwner) { + if (it == null) return@observe + if (!updatesViewModel.updatesList.value?.first.isNullOrEmpty()) { + return@observe + } + loadData(it) + } + + updatesViewModel.exceptionsLiveData.observe(viewLifecycleOwner) { + handleExceptionsCommon(it) } val recyclerView = binding.recyclerView @@ -144,9 +159,9 @@ class UpdatesFragment : TimeoutFragment(R.layout.fragment_updates), FusedAPIInte } } - if (it.second != ResultStatus.OK) { + /*if (it.second != ResultStatus.OK) { onTimeout() - } + }*/ } } @@ -166,7 +181,7 @@ class UpdatesFragment : TimeoutFragment(R.layout.fragment_updates), FusedAPIInte ).show(childFragmentManager, "UpdatesFragment") } - override fun onTimeout() { + /*override fun onTimeout() { if (!isTimeoutDialogDisplayed()) { stopLoadingUI() displayTimeoutAlertDialog( @@ -194,11 +209,54 @@ class UpdatesFragment : TimeoutFragment(R.layout.fragment_updates), FusedAPIInte allowCancel = true, ) } + }*/ + + override fun onTimeout( + exception: Exception, + predefinedDialog: AlertDialog.Builder + ): AlertDialog.Builder? { + return predefinedDialog.apply { + if (exception is GPlayException) { + setMessage(R.string.timeout_desc_gplay) + setNegativeButton(R.string.open_settings) { _, _ -> + openSettings() + } + } else { + setMessage(R.string.timeout_desc_cleanapk) + } + } + } + + override fun onSignInError( + exception: GPlayLoginException, + predefinedDialog: AlertDialog.Builder + ): AlertDialog.Builder? { + return predefinedDialog.apply { + setNegativeButton(R.string.open_settings) { _, _ -> + openSettings() + } + } + } + + override fun onDataLoadError( + exception: Exception, + predefinedDialog: AlertDialog.Builder + ): AlertDialog.Builder? { + return predefinedDialog.apply { + if (exception is GPlayException) { + setNegativeButton(R.string.open_settings) { _, _ -> + openSettings() + } + } + } } - override fun refreshData(authData: AuthData) { + override fun loadData(authObjectList: List) { showLoadingUI() - updatesViewModel.getUpdates(authData) + updatesViewModel.loadData(authObjectList) { + clearAndRestartGPlayLogin() + true + } binding.button.setOnClickListener { UpdatesWorkManager.startUpdateAllWork(requireContext().applicationContext) observeUpdateWork() @@ -222,14 +280,14 @@ class UpdatesFragment : TimeoutFragment(R.layout.fragment_updates), FusedAPIInte } } - private fun showLoadingUI() { + override fun showLoadingUI() { binding.button.isEnabled = false binding.noUpdates.visibility = View.GONE binding.progressBar.visibility = View.VISIBLE binding.recyclerView.visibility = View.INVISIBLE } - private fun stopLoadingUI() { + override fun stopLoadingUI() { binding.progressBar.visibility = View.GONE binding.recyclerView.visibility = View.VISIBLE } @@ -239,7 +297,7 @@ class UpdatesFragment : TimeoutFragment(R.layout.fragment_updates), FusedAPIInte appProgressViewModel.downloadProgress.observe(viewLifecycleOwner) { updateProgressOfDownloadingItems(binding.recyclerView, it) } - resetTimeoutDialogLock() +// resetTimeoutDialogLock() } private fun observeDownloadList() { 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 fe1c036faa4f9e25fc2e27052d9054765c887e3a..9be088227179babdc3f04b51deb2d5fbd788cbe0 100644 --- a/app/src/main/java/foundation/e/apps/updates/UpdatesViewModel.kt +++ b/app/src/main/java/foundation/e/apps/updates/UpdatesViewModel.kt @@ -19,16 +19,19 @@ package foundation.e.apps.updates import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.work.WorkInfo import com.aurora.gplayapi.data.models.AuthData import dagger.hilt.android.lifecycle.HiltViewModel import foundation.e.apps.api.fused.FusedAPIRepository import foundation.e.apps.api.fused.data.FusedApp +import foundation.e.apps.login.AuthObject import foundation.e.apps.updates.manager.UpdatesManagerRepository import foundation.e.apps.utils.enums.ResultStatus import foundation.e.apps.utils.enums.Status +import foundation.e.apps.utils.exceptions.CleanApkException +import foundation.e.apps.utils.exceptions.GPlayException +import foundation.e.apps.utils.parentFragment.LoadingViewModel import kotlinx.coroutines.launch import javax.inject.Inject @@ -36,14 +39,52 @@ import javax.inject.Inject class UpdatesViewModel @Inject constructor( private val updatesManagerRepository: UpdatesManagerRepository, private val fusedAPIRepository: FusedAPIRepository -) : ViewModel() { +) : LoadingViewModel() { val updatesList: MutableLiveData, ResultStatus?>> = MutableLiveData() - fun getUpdates(authData: AuthData) { + fun loadData( + authObjectList: List, + retryBlock: (failedObjects: List) -> Boolean, + ) { + super.onLoadData(authObjectList, { successAuthList, _ -> + + successAuthList.find { it is AuthObject.GPlayAuth }?.run { + getUpdates(result.data!! as AuthData) + return@onLoadData + } + + successAuthList.find { it is AuthObject.CleanApk }?.run { + getUpdates(AuthData("", "")) + return@onLoadData + } + }, retryBlock) + } + + fun getUpdates(authData: AuthData?) { viewModelScope.launch { - val updatesResult = updatesManagerRepository.getUpdates(authData) + val updatesResult = if (authData != null) + updatesManagerRepository.getUpdates(authData) + else updatesManagerRepository.getUpdatesOSS() updatesList.postValue(updatesResult) + + if (updatesResult.second != ResultStatus.OK) { + val exception = + if (authData != null && + (authData.aasToken.isNotBlank() || authData.authToken.isNotBlank()) + ) { + GPlayException( + updatesResult.second == ResultStatus.TIMEOUT, + "Data load error" + ) + } else CleanApkException( + updatesResult.second == ResultStatus.TIMEOUT, + "Data load error" + ) + + exceptionsList.add(exception) + exceptionsLiveData.postValue(exceptionsList) + } } } 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 5fd534f7ad62f1482751c1dc15916693f8fd578e..06d006aa06aad30933ba52ef2488fe2bef514cb2 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 @@ -83,6 +83,37 @@ class UpdatesManagerImpl @Inject constructor( return Pair(nonFaultyUpdateList, status) } + suspend fun getUpdatesOSS(): 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 cleanAPKResult = fusedAPIRepository.getApplicationDetails( + pkgList, + AuthData("", ""), + Origin.CLEANAPK + ) + cleanAPKResult.first.forEach { + if (it.status == Status.UPDATABLE && it.filterLevel.isUnFiltered()) updateList.add( + it + ) + } + cleanAPKResult.second.let { + if (it != ResultStatus.OK) { + status = it + } + } + } + + val nonFaultyUpdateList = faultyAppRepository.removeFaultyApps(updateList) + return Pair(nonFaultyUpdateList, 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 0805a8a5bd6b54b23c9745153b65a8361c1d7250..c56c280c4f279d9fca453de49bf4f72cf3833379 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 @@ -39,6 +39,10 @@ class UpdatesManagerRepository @Inject constructor( } } + suspend fun getUpdatesOSS(): Pair, ResultStatus> { + return updatesManagerImpl.getUpdatesOSS() + } + 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 e8965a51d8f4624c9a4af43eab0379d74d14b453..7aa77888d4d1383e9c5fd326930cc28e01979f33 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 @@ -26,6 +26,7 @@ import foundation.e.apps.manager.workmanager.InstallWorkManager import foundation.e.apps.updates.UpdatesNotifier import foundation.e.apps.utils.enums.Origin import foundation.e.apps.utils.enums.Type +import foundation.e.apps.utils.enums.User import foundation.e.apps.utils.modules.DataStoreModule import timber.log.Timber import java.io.ByteArrayOutputStream @@ -66,11 +67,40 @@ class UpdatesWorker @AssistedInject constructor( } } + private fun getUser(): User { + return dataStoreModule.getUserType() + } + private suspend fun checkForUpdates() { loadSettings() val isConnectedToUnmeteredNetwork = isConnectedToUnmeteredNetwork(applicationContext) + val appsNeededToUpdate = mutableListOf() + val user = getUser() val authData = getAuthData() - val appsNeededToUpdate = updatesManagerRepository.getUpdates(authData).first + + if (user in listOf(User.ANONYMOUS, User.GOOGLE) && authData != null) { + /* + * Signifies valid Google user and valid auth data to update + * apps from Google Play store. + * The user check will be more useful in No Google mode. + */ + appsNeededToUpdate.addAll(updatesManagerRepository.getUpdates(authData).first) + } else if (user != User.UNAVAILABLE) { + /* + * If authData is null, update apps from cleanapk only. + */ + appsNeededToUpdate.addAll(updatesManagerRepository.getUpdatesOSS().first) + } else { + /* + * If user in UNAVAILABLE, don't do anything. + */ + return + } + + /* + * Show notification only if enabled. + * Issue: https://gitlab.e.foundation/e/backlog/-/issues/5376 + */ if (isAutoUpdate && shouldShowNotification) { handleNotification(appsNeededToUpdate.size, isConnectedToUnmeteredNetwork) } @@ -78,7 +108,12 @@ class UpdatesWorker @AssistedInject constructor( triggerUpdateProcessOnSettings( isConnectedToUnmeteredNetwork, appsNeededToUpdate, - authData + /* + * If authData is null, only cleanApk data will be present + * in appsNeededToUpdate list. Hence it is safe to proceed with + * blank AuthData. + */ + authData ?: AuthData("", ""), ) } @@ -113,9 +148,10 @@ class UpdatesWorker @AssistedInject constructor( } } - private fun getAuthData(): AuthData { + private fun getAuthData(): AuthData? { val authDataJson = dataStoreModule.getAuthDataSync() - return gson.fromJson(authDataJson, AuthData::class.java) + return if (authDataJson.isBlank()) return null + else gson.fromJson(authDataJson, AuthData::class.java) } private suspend fun startUpdateProcess( diff --git a/app/src/main/java/foundation/e/apps/utils/Constants.kt b/app/src/main/java/foundation/e/apps/utils/Constants.kt new file mode 100644 index 0000000000000000000000000000000000000000..d8083adafa4dbd5b619ccbe6d92914660371aa7b --- /dev/null +++ b/app/src/main/java/foundation/e/apps/utils/Constants.kt @@ -0,0 +1,9 @@ +package foundation.e.apps.utils + +object Constants { + const val timeoutDurationInMillis: Long = 10000 + + const val PREFERENCE_SHOW_FOSS = "showFOSSApplications" + const val PREFERENCE_SHOW_PWA = "showPWAApplications" + const val PREFERENCE_SHOW_GPLAY = "showAllApplications" +} diff --git a/app/src/main/java/foundation/e/apps/utils/exceptions/CleanApkException.kt b/app/src/main/java/foundation/e/apps/utils/exceptions/CleanApkException.kt new file mode 100644 index 0000000000000000000000000000000000000000..5fed1e3a0d3c36160f0b99b0bb25846ab7fccdf2 --- /dev/null +++ b/app/src/main/java/foundation/e/apps/utils/exceptions/CleanApkException.kt @@ -0,0 +1,26 @@ +/* + * Copyright (C) 2019-2022 E FOUNDATION + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * 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.exceptions + +/** + * This exception is for all CleanApk data loading exceptions. + */ +class CleanApkException( + val isTimeout: Boolean, + message: String? = null, +) : LoginException(message) diff --git a/app/src/main/java/foundation/e/apps/utils/exceptions/GPlayException.kt b/app/src/main/java/foundation/e/apps/utils/exceptions/GPlayException.kt new file mode 100644 index 0000000000000000000000000000000000000000..5a01fb4d36f69ee1f0ef1782e4f859fd02719239 --- /dev/null +++ b/app/src/main/java/foundation/e/apps/utils/exceptions/GPlayException.kt @@ -0,0 +1,26 @@ +/* + * Copyright (C) 2019-2022 E FOUNDATION + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * 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.exceptions + +/** + * This exception is for all Google Play network calls or other GPlay related exceptions. + */ +open class GPlayException( + val isTimeout: Boolean, + message: String? = null, +) : LoginException(message) diff --git a/app/src/main/java/foundation/e/apps/utils/exceptions/GPlayLoginException.kt b/app/src/main/java/foundation/e/apps/utils/exceptions/GPlayLoginException.kt new file mode 100644 index 0000000000000000000000000000000000000000..2902ba28f8ca59a83c36b29f85f78820eb8c1f95 --- /dev/null +++ b/app/src/main/java/foundation/e/apps/utils/exceptions/GPlayLoginException.kt @@ -0,0 +1,31 @@ +/* + * Copyright (C) 2019-2022 MURENA SAS + * + * 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.exceptions + +import foundation.e.apps.utils.enums.User + +/** + * Parent class for all GPlay login related errors. + * Examples: + * unable to get anonymous token, AuthData validation failed, aasToken error etc. + */ +open class GPlayLoginException( + isTimeout: Boolean, + message: String? = null, + val user: User, +) : GPlayException(isTimeout, message) diff --git a/app/src/main/java/foundation/e/apps/utils/exceptions/GPlayValidationException.kt b/app/src/main/java/foundation/e/apps/utils/exceptions/GPlayValidationException.kt new file mode 100644 index 0000000000000000000000000000000000000000..0257dc91cfeca16fd0e0d7e2f94f2423ff49ec75 --- /dev/null +++ b/app/src/main/java/foundation/e/apps/utils/exceptions/GPlayValidationException.kt @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2019-2022 E FOUNDATION + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * 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.exceptions + +import foundation.e.apps.utils.enums.User + +/** + * This exception is specifically used when a GPlay auth data could not be validated. + * This is not the case as timeout, this exception usually means the server informed that the + * current auth data is not valid. + * Use [networkCode] to be sure that the server call was successful (should be 200). + */ +class GPlayValidationException( + message: String, + user: User, + val networkCode: Int, +) : GPlayLoginException(false, message, user) diff --git a/app/src/main/java/foundation/e/apps/utils/exceptions/LoginException.kt b/app/src/main/java/foundation/e/apps/utils/exceptions/LoginException.kt new file mode 100644 index 0000000000000000000000000000000000000000..01011dbdf64b004972573a5854caf1c19a69191f --- /dev/null +++ b/app/src/main/java/foundation/e/apps/utils/exceptions/LoginException.kt @@ -0,0 +1,24 @@ +/* + * Copyright (C) 2019-2022 E FOUNDATION + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * 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.exceptions + +/** + * Super class for all Login related exceptions. + * https://gitlab.e.foundation/e/backlog/-/issues/5680 + */ +open class LoginException(message: String?) : Exception(message) diff --git a/app/src/main/java/foundation/e/apps/utils/exceptions/UnknownSourceException.kt b/app/src/main/java/foundation/e/apps/utils/exceptions/UnknownSourceException.kt new file mode 100644 index 0000000000000000000000000000000000000000..10e5ab36211413b305f2d8573c3d90c4a99d441a --- /dev/null +++ b/app/src/main/java/foundation/e/apps/utils/exceptions/UnknownSourceException.kt @@ -0,0 +1,23 @@ +/* + * Copyright (C) 2019-2022 E FOUNDATION + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * 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.exceptions + +/** + * Generic exception class - used to define unknown errors. + */ +class UnknownSourceException : LoginException("") 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 4b82fe260bcec174c1dcbf84c26f075776406891..a533a77e5ecb83dca582a1cdfcd61656957bad56 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 @@ -48,7 +48,6 @@ import javax.inject.Singleton object CommonUtilsModule { val LIST_OF_NULL = listOf("null") - const val timeoutDurationInMillis: Long = 25000 // Issue: https://gitlab.e.foundation/e/backlog/-/issues/5709 const val NETWORK_CODE_SUCCESS = 200 diff --git a/app/src/main/java/foundation/e/apps/utils/modules/DataStoreModule.kt b/app/src/main/java/foundation/e/apps/utils/modules/DataStoreModule.kt index a85d033654d3d08234b9b8e72583bc9f236b3b6f..e30305e9b33100047e3f9b1c65d9e64374bbbb75 100644 --- a/app/src/main/java/foundation/e/apps/utils/modules/DataStoreModule.kt +++ b/app/src/main/java/foundation/e/apps/utils/modules/DataStoreModule.kt @@ -40,8 +40,10 @@ class DataStoreModule @Inject constructor( private val gson: Gson ) { - private val preferenceDataStoreName = "Settings" - private val Context.dataStore by preferencesDataStore(preferenceDataStoreName) + companion object { + private const val preferenceDataStoreName = "Settings" + val Context.dataStore by preferencesDataStore(preferenceDataStoreName) + } private val AUTHDATA = stringPreferencesKey("authData") private val EMAIL = stringPreferencesKey("email") @@ -131,11 +133,23 @@ class DataStoreModule @Inject constructor( } } - suspend fun getEmail(): String { - return emailData.first() + fun getEmail(): String { + return runBlocking { + emailData.first() + } } suspend fun getAASToken(): String { return aasToken.first() } + + fun getUserType(): User { + return runBlocking { + userType.first().run { + val userStrings = User.values().map { it.name } + if (this !in userStrings) User.UNAVAILABLE + else User.valueOf(this) + } + } + } } diff --git a/app/src/main/java/foundation/e/apps/utils/modules/PreferenceManagerModule.kt b/app/src/main/java/foundation/e/apps/utils/modules/PreferenceManagerModule.kt index 9be9b4a3a55e3826c2e95a048bb75acd07892c7d..0fa6cf9c9b9972f143b4aed1214f61d6ce587245 100644 --- a/app/src/main/java/foundation/e/apps/utils/modules/PreferenceManagerModule.kt +++ b/app/src/main/java/foundation/e/apps/utils/modules/PreferenceManagerModule.kt @@ -22,6 +22,9 @@ import android.content.Context import androidx.preference.PreferenceManager import dagger.hilt.android.qualifiers.ApplicationContext import foundation.e.apps.OpenForTesting +import foundation.e.apps.utils.Constants.PREFERENCE_SHOW_FOSS +import foundation.e.apps.utils.Constants.PREFERENCE_SHOW_GPLAY +import foundation.e.apps.utils.Constants.PREFERENCE_SHOW_PWA import javax.inject.Inject import javax.inject.Singleton @@ -34,8 +37,8 @@ class PreferenceManagerModule @Inject constructor( private val preferenceManager = PreferenceManager.getDefaultSharedPreferences(context) fun preferredApplicationType(): String { - val showFOSSApplications = preferenceManager.getBoolean("showFOSSApplications", false) - val showPWAApplications = preferenceManager.getBoolean("showPWAApplications", false) + val showFOSSApplications = preferenceManager.getBoolean(PREFERENCE_SHOW_FOSS, false) + val showPWAApplications = preferenceManager.getBoolean(PREFERENCE_SHOW_PWA, false) return when { showFOSSApplications -> "open" @@ -44,9 +47,9 @@ class PreferenceManagerModule @Inject constructor( } } - fun isOpenSourceSelected() = preferenceManager.getBoolean("showFOSSApplications", true) - fun isPWASelected() = preferenceManager.getBoolean("showPWAApplications", true) - fun isGplaySelected() = preferenceManager.getBoolean("showAllApplications", true) + fun isOpenSourceSelected() = preferenceManager.getBoolean(PREFERENCE_SHOW_FOSS, true) + fun isPWASelected() = preferenceManager.getBoolean(PREFERENCE_SHOW_PWA, true) + fun isGplaySelected() = preferenceManager.getBoolean(PREFERENCE_SHOW_GPLAY, true) fun autoUpdatePreferred(): Boolean { return preferenceManager.getBoolean("updateInstallAuto", false) diff --git a/app/src/main/java/foundation/e/apps/utils/parentFragment/LoadingViewModel.kt b/app/src/main/java/foundation/e/apps/utils/parentFragment/LoadingViewModel.kt new file mode 100644 index 0000000000000000000000000000000000000000..cbd0ab493ca7bef005ff9db5b96acf7ed4ace54a --- /dev/null +++ b/app/src/main/java/foundation/e/apps/utils/parentFragment/LoadingViewModel.kt @@ -0,0 +1,79 @@ +/* + * Copyright (C) 2019-2022 MURENA SAS + * + * 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 androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import foundation.e.apps.login.AuthObject +import foundation.e.apps.utils.exceptions.GPlayValidationException +import foundation.e.apps.utils.exceptions.UnknownSourceException + +abstract class LoadingViewModel : ViewModel() { + + companion object { + private var autoRetried = false + } + + val exceptionsLiveData: MutableLiveData> = MutableLiveData() + val exceptionsList = ArrayList() + + /** + * Call this method from ViewModel. + * + * @param authObjectList List obtained from login process. + * @param loadingBlock Define how to load data in this method. + * @param retryBlock Define retry mechanism for failed AuthObject. + * Return `true` to signify the failure event is consumed by the block and no further + * processing on failed AuthObject is needed. + */ + fun onLoadData( + authObjectList: List, + loadingBlock: (successObjects: List, failedObjects: List) -> Unit, + retryBlock: (failedObjects: List) -> Boolean, + ) { + + exceptionsList.clear() + + val successAuthList = authObjectList.filter { it.result.isSuccess() } + val failedAuthList = authObjectList.filter { !it.result.isSuccess() } + + failedAuthList.forEach { + exceptionsList.add(it.result.exception ?: UnknownSourceException()) + } + + exceptionsList.find { + it is GPlayValidationException + }?.run { + if (!autoRetried && retryBlock(failedAuthList)) { + autoRetried = true + exceptionsList.clear() + return + } + } + + loadingBlock(successAuthList, failedAuthList) + + if (successAuthList.isEmpty() && exceptionsList.isNotEmpty()) { + /* + * As no authentication is successful, nothing can be loaded, + * post the exceptions. + */ + exceptionsLiveData.postValue(exceptionsList) + } + } +} diff --git a/app/src/main/java/foundation/e/apps/utils/parentFragment/TimeoutFragment.kt b/app/src/main/java/foundation/e/apps/utils/parentFragment/TimeoutFragment.kt index e5e4c6453d5244e475da70b6d227374ee9752fa4..0744b390edcb67910994dd4134a62994ce78f6cf 100644 --- a/app/src/main/java/foundation/e/apps/utils/parentFragment/TimeoutFragment.kt +++ b/app/src/main/java/foundation/e/apps/utils/parentFragment/TimeoutFragment.kt @@ -1,5 +1,5 @@ /* - * Copyright (C) 2022 ECORP + * Copyright (C) 2019-2022 MURENA SAS * * 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 @@ -17,202 +17,348 @@ 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.core.view.isVisible import androidx.fragment.app.Fragment -import com.aurora.gplayapi.data.models.AuthData -import foundation.e.apps.MainActivityViewModel +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModelProvider import foundation.e.apps.R +import foundation.e.apps.databinding.DialogErrorLogBinding +import foundation.e.apps.login.AuthObject +import foundation.e.apps.login.LoginSourceGPlay +import foundation.e.apps.login.LoginViewModel +import foundation.e.apps.utils.enums.User +import foundation.e.apps.utils.exceptions.CleanApkException +import foundation.e.apps.utils.exceptions.GPlayException +import foundation.e.apps.utils.exceptions.GPlayLoginException +import foundation.e.apps.utils.exceptions.GPlayValidationException +import foundation.e.apps.utils.exceptions.UnknownSourceException -/* - * 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 +/** + * Parent class of all fragments. + * + * Mostly contains UI related code regarding dialogs to display. + * Does also provide some interaction with [LoginViewModel]. + * + * https://gitlab.e.foundation/e/backlog/-/issues/5680 */ 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 + val loginViewModel: LoginViewModel by lazy { + ViewModelProvider(requireActivity())[LoginViewModel::class.java] + } + + /** + * Fragments observe this list to load data. + * Fragments should not observe [loginViewModel]'s authObjects. */ - private var timeoutAlertDialog: AlertDialog? = null + val authObjects: MutableLiveData?> = MutableLiveData() + + abstract fun loadData(authObjectList: List) + + abstract fun showLoadingUI() - abstract fun onTimeout() + abstract fun stopLoadingUI() + + /** + * Override to contain code to execute in case of timeout. + * Do not call this function directly, use [showTimeout] for that. + * + * @param predefinedDialog An AlertDialog builder, already having some properties, + * Fragment can change the dialog properties and return as the result. + * By default: + * 1. Dialog title set to [R.string.timeout_title] + * 2. Dialog content set to [R.string.timeout_desc_cleanapk]. + * 3. Dialog can show technical error info on clicking "More Info" + * 4. Has a positive button "Retry" which calls [LoginViewModel.startLoginFlow]. + * 5. Has a negative button "Close" which just closes the dialog. + * 6. Dialog is cancellable. + * + * @return An alert dialog (created from [predefinedDialog]) to show a timeout dialog, + * or null to not show anything. + */ + abstract fun onTimeout( + exception: Exception, + predefinedDialog: AlertDialog.Builder, + ): AlertDialog.Builder? - /* - * 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. + /** + * Override to contain code to execute in case of other sign in error. + * This can only happen for GPlay data as cleanapk does not need any login. + * Do not call this function directly, use [showSignInError] for that. * - * Setting the value to true is automatically done from displayTimeoutAlertDialog(). - * To set it as false, call resetTimeoutDialogLock(). + * @param predefinedDialog An AlertDialog builder, already having some properties, + * Fragment can change the dialog properties and return as the result. + * By default: + * 1. Dialog title set to [R.string.anonymous_login_failed] or [R.string.sign_in_failed_title] + * 2. Content set to [R.string.anonymous_login_failed_desc] or [R.string.sign_in_failed_desc] + * 3. Dialog can show technical error info on clicking "More Info" + * 4. Has a positive button "Retry" which calls [LoginViewModel.startLoginFlow], + * passing the list of failed auth types. + * 5. Has a negative button "Logout" which logs the user out of App Lounge. + * 6. Dialog is cancellable. * - * 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. + * @return An alert dialog (created from [predefinedDialog]) to show a timeout dialog, + * or null to not show anything. */ - private var timeoutDialogShownLock: Boolean = false + abstract fun onSignInError( + exception: GPlayLoginException, + predefinedDialog: AlertDialog.Builder, + ): AlertDialog.Builder? - /* - * 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. + /** + * Override to contain code to execute for error during loading data. + * Do not call this function directly, use [showDataLoadError] for that. + * + * @param predefinedDialog An AlertDialog builder, already having some properties, + * Fragment can change the dialog properties and return as the result. + * By default: + * 1. Dialog title set to [R.string.data_load_error]. + * 2. Dialog content set to [R.string.data_load_error_desc]. + * 3. Dialog can show technical error info on clicking "More Info" + * 4. Has a positive button "Retry" which calls [loadData]. + * 5. Has a negative button "Close" which just closes the dialog. + * 6. Dialog is cancellable. */ - fun resetTimeoutDialogLock() { - timeoutDialogShownLock = false + abstract fun onDataLoadError( + exception: Exception, + predefinedDialog: AlertDialog.Builder, + ): AlertDialog.Builder? + + /** + * Crucial to call this, other wise fragments will never receive any authentications. + */ + fun setupListening() { + loginViewModel.authObjects.observe(viewLifecycleOwner) { + authObjects.postValue(it) + } } - /* - * Recommended to put code to refresh data inside this block. - * But call refreshDataOrRefreshToken() to execute the refresh. + /** + * Call this to repopulate authObjects with old data, this can be used to refresh data + * from inside the observer on [authObjects]. */ - abstract fun refreshData(authData: AuthData) + fun repostAuthObjects() { + authObjects.postValue(loginViewModel.authObjects.value) + } - /* - * 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. + /** + * Clears saved GPlay AuthData and restarts login process to get */ - fun refreshDataOrRefreshToken(mainActivityViewModel: MainActivityViewModel) { - if (mainActivityViewModel.internetConnection.value == true) { - mainActivityViewModel.authData.value?.let { authData -> - dismissTimeoutDialog() - refreshData(authData) - } ?: run { - if (mainActivityViewModel.authValidity.value != null) { // checking at least authvalidity is checked for once - mainActivityViewModel.retryFetchingTokenAfterTimeout() - } + fun clearAndRestartGPlayLogin() { + loginViewModel.startLoginFlow(listOf(LoginSourceGPlay::class.java.simpleName)) + } + + /** + * Store the last shown dialog, so that when a new dialog is to be shown, + * the old dialog can be automatically dismissed. + */ + private var lastDialog: AlertDialog? = null + + /** + * Show a dialog, dismiss previously shown dialog in [lastDialog]. + */ + private fun showAndSetDialog(alertDialogBuilder: AlertDialog.Builder) { + alertDialogBuilder.create().run { + if (lastDialog?.isShowing == true) { + lastDialog?.dismiss() } + this.show() + lastDialog = this } } /** - * 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. + * Call to trigger [onTimeout]. + * Can be called from anywhere in the fragment. * - * Issue: https://gitlab.e.foundation/e/backlog/-/issues/5404 - * Issue: https://gitlab.e.foundation/e/backlog/-/issues/5413 + * Calls [onTimeout], which may return a [AlertDialog.Builder] + * instance if it deems fit. Else it may return null, in which case no timeout dialog + * is shown to the user. */ - 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, - ) { + fun showTimeout(exception: Exception) { + val dialogView = DialogErrorLogBinding.inflate(requireActivity().layoutInflater) + dialogView.apply { + moreInfo.setOnClickListener { + logDisplay.isVisible = true + moreInfo.isVisible = false + } - /* - * If timeout dialog is already shown, don't proceed. - */ - if (timeoutFragment.timeoutDialogShownLock) { - return + val logToDisplay = exception.message ?: "" + + if (logToDisplay.isNotBlank()) { + logDisplay.text = logToDisplay + moreInfo.isVisible = true + } + } + val predefinedDialog = AlertDialog.Builder(requireActivity()).apply { + setTitle(R.string.timeout_title) + setMessage(R.string.timeout_desc_cleanapk) + setView(dialogView.root) + setPositiveButton(R.string.retry) { _, _ -> + showLoadingUI() + loginViewModel.startLoginFlow() + } + setNegativeButton(R.string.close, null) + setCancelable(true) } - val timeoutAlertDialogBuilder = AlertDialog.Builder(activity).apply { + onTimeout( + exception, + predefinedDialog, + )?.run { + stopLoadingUI() + showAndSetDialog(this) + } + } - /* - * Set title. - */ - setTitle(R.string.timeout_title) + /** + * Call to trigger [onSignInError]. + * Only works if last loginUiAction was a failure case. Else nothing happens. + * + * Calls [onSignInError], which may return a [AlertDialog.Builder] + * instance if it deems fit. Else it may return null, at which case no error dialog + * is shown to the user. + */ + fun showSignInError(exception: GPlayLoginException) { - 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) + val dialogView = DialogErrorLogBinding.inflate(requireActivity().layoutInflater) + dialogView.apply { + + moreInfo.setOnClickListener { + logDisplay.isVisible = true + moreInfo.isVisible = false } - /* - * Set message - */ - setMessage(message) - - /* - * Set buttons. - */ - positiveButtonText?.let { - setPositiveButton(it) { _, _ -> - positiveButtonBlock?.invoke() - } + val logToDisplay = exception.message ?: "" + if (logToDisplay.isNotBlank()) { + logDisplay.text = logToDisplay + moreInfo.isVisible = true } - negativeButtonText?.let { - setNegativeButton(it) { _, _ -> - negativeButtonBlock?.invoke() - } + } + val predefinedDialog = AlertDialog.Builder(requireActivity()).apply { + if (exception.user == User.GOOGLE) { + setTitle(R.string.sign_in_failed_title) + setMessage(R.string.sign_in_failed_desc) + } else { + setTitle(R.string.anonymous_login_failed) + setMessage(R.string.anonymous_login_failed_desc) } - neutralButtonText?.let { - setNeutralButton(it) { _, _ -> - neutralButtonBlock?.invoke() + + setView(dialogView.root) + + setPositiveButton(R.string.retry) { _, _ -> + showLoadingUI() + when (exception) { + is GPlayValidationException -> clearAndRestartGPlayLogin() + else -> loginViewModel.startLoginFlow() } } + setNegativeButton(R.string.logout) { _, _ -> + loginViewModel.logout() + } + setCancelable(true) } - /* - * Dismiss alert dialog if already being shown - */ - try { - timeoutAlertDialog?.dismiss() - } catch (_: Exception) { + onSignInError( + exception, + predefinedDialog, + )?.run { + stopLoadingUI() + showAndSetDialog(this) } - - 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. + * Call when there is an error during loading data (not error during authentication.) + * + * Calls [onDataLoadError] which may return a [AlertDialog.Builder] + * instance if it deems fit. Else it may return null, at which case no error dialog + * is shown to the user. */ - fun isTimeoutDialogDisplayed(): Boolean { - return timeoutAlertDialog?.isShowing == true + fun showDataLoadError(exception: Exception) { + + val dialogView = DialogErrorLogBinding.inflate(requireActivity().layoutInflater) + dialogView.apply { + moreInfo.setOnClickListener { + logDisplay.isVisible = true + moreInfo.isVisible = false + } + val logToDisplay = exception.message ?: "" + if (logToDisplay.isNotBlank()) { + logDisplay.text = logToDisplay + moreInfo.isVisible = true + } + } + + val predefinedDialog = AlertDialog.Builder(requireActivity()).apply { + setTitle(R.string.data_load_error) + setMessage(R.string.data_load_error_desc) + setView(dialogView.root) + setPositiveButton(R.string.retry) { _, _ -> + showLoadingUI() + authObjects.value?.let { loadData(it) } + } + setNegativeButton(R.string.close, null) + setCancelable(true) + } + + onDataLoadError( + exception, + predefinedDialog, + )?.run { + stopLoadingUI() + showAndSetDialog(this) + } } /** - * 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. + * Common code to handle exceptions / errors during data loading. + * Can be overridden in child fragments. */ - fun dismissTimeoutDialog() { - if (isTimeoutDialogDisplayed()) { - try { - timeoutAlertDialog?.dismiss() - } catch (_: Exception) { + open fun handleExceptionsCommon(exceptions: List) { + val cleanApkException = exceptions.find { it is CleanApkException }?.run { + this as CleanApkException + } + val gPlayException = exceptions.find { it is GPlayException }?.run { + this as GPlayException + } + val unknownSourceException = exceptions.find { it is UnknownSourceException } + + /* + * Take caution altering the cases. + * Cases to be defined from most restrictive to least restrictive. + */ + when { + // Handle timeouts + cleanApkException?.isTimeout == true -> showTimeout(cleanApkException) + gPlayException?.isTimeout == true -> showTimeout(gPlayException) + + // Handle sign-in error + gPlayException is GPlayLoginException -> showSignInError(gPlayException) + + // Other errors - data loading error + gPlayException != null -> showDataLoadError(gPlayException) + cleanApkException != null -> showDataLoadError(cleanApkException) + + // Unknown exception + unknownSourceException != null -> { + showAndSetDialog( + AlertDialog.Builder(requireActivity()) + .setTitle(R.string.unknown_error) + .setPositiveButton(R.string.close, null) + ) } } } + + /** + * Clear stale AuthObjects on fragment destruction. + * Useful if sources are changed in Settings and new AuthObjects are needed. + */ + override fun onDestroyView() { + super.onDestroyView() + authObjects.value = null + } } diff --git a/app/src/main/res/layout/custom_preference.xml b/app/src/main/res/layout/custom_preference.xml index 0947f7933c92fb57879ff130bf1748bb04550311..84793f87f27e3f52166e0d848e810e2470506f7a 100644 --- a/app/src/main/res/layout/custom_preference.xml +++ b/app/src/main/res/layout/custom_preference.xml @@ -81,9 +81,9 @@ android:layout_marginStart="18dp" android:textColor="?android:textColorPrimary" android:textSize="15sp" - app:layout_constraintBottom_toBottomOf="@id/avatar" app:layout_constraintStart_toEndOf="@id/avatar" - app:layout_constraintTop_toTopOf="@id/avatar" /> + app:layout_constraintTop_toTopOf="@id/avatar" + app:layout_constraintVertical_chainStyle="packed"/> + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values-night/colors.xml b/app/src/main/res/values-night/colors.xml index 917cadc46808008ffe90243db9e35d5d08e6ef9c..ad2e66a6f39cd9762e1f69942b0be2727e1feb4b 100644 --- a/app/src/main/res/values-night/colors.xml +++ b/app/src/main/res/values-night/colors.xml @@ -25,6 +25,8 @@ #262626 + #353535 + @color/colorNavBar \ No newline at end of file diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index d76e68362aa32df4d395ca7d8445d6e52d085571..274a54b2966af91a8065835d3e4024ef81a0fa30 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -38,4 +38,6 @@ #FB3846 #FFEB3B + #EDEDED + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index dc6227637de300b681ab533aa9aba343504dc79a..702744b4190457d96eaa0b014e93da17834e0ed7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -84,6 +84,7 @@ N/A Open Retry + Close More Update Ratings @@ -111,6 +112,7 @@ Your application was not found. Something went wrong! Show more + Cannot show Google Play app when only open source apps are allowed. @@ -171,10 +173,17 @@ Some network issue is preventing fetching all applications. Open Settings - + More info + + + Error occurred while loading apps. + This can be because server is not responding or other server error.\n\nPlease retry to try again, or try later. + Google sign in failed! Due to a network error, App Lounge was not able to get Google sign-in data. + Anonymous login failed! + This can be because token could not be generated / verified or other reasons.\n\nPress Retry to try again. %1$s]]>