diff --git a/app/build.gradle b/app/build.gradle index 2787d2af37d466a7e9731aa6ab95654f299d94b0..461d23ecbce16b1d86d51a8572a2ef3a5624fc42 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -11,7 +11,7 @@ plugins { def versionMajor = 2 def versionMinor = 6 -def versionPatch = 1 +def versionPatch = 2 def getGitHash = { -> def stdOut = new ByteArrayOutputStream() diff --git a/app/src/main/java/foundation/e/apps/AppLoungeApplication.kt b/app/src/main/java/foundation/e/apps/AppLoungeApplication.kt index a9044e3fbeb791a6d08ce2d5e77898a0b083acc6..368c89a3c97baed45e11851f772c24498fac62b0 100644 --- a/app/src/main/java/foundation/e/apps/AppLoungeApplication.kt +++ b/app/src/main/java/foundation/e/apps/AppLoungeApplication.kt @@ -26,6 +26,7 @@ import androidx.hilt.work.HiltWorkerFactory import androidx.work.Configuration import androidx.work.ExistingPeriodicWorkPolicy import dagger.hilt.android.HiltAndroidApp +import foundation.e.apps.data.Constants.TAG_AUTHDATA_DUMP import foundation.e.apps.data.preference.DataStoreModule import foundation.e.apps.data.preference.PreferenceManagerModule import foundation.e.apps.install.pkg.PkgManagerBR @@ -81,7 +82,7 @@ class AppLoungeApplication : Application(), Configuration.Provider { Telemetry.init(BuildConfig.SENTRY_DSN, this) plant(object : Timber.Tree() { override fun log(priority: Int, tag: String?, message: String, t: Throwable?) { - if (priority <= Log.WARN) { + if (priority <= Log.WARN && tag != TAG_AUTHDATA_DUMP) { return } Log.println(priority, tag, message) diff --git a/app/src/main/java/foundation/e/apps/MainActivity.kt b/app/src/main/java/foundation/e/apps/MainActivity.kt index c4d95fceabed9f5e7ae9f5c3ad18de7e0f191b82..b548c942319a3ec27b70d7d4419653a03eb497c5 100644 --- a/app/src/main/java/foundation/e/apps/MainActivity.kt +++ b/app/src/main/java/foundation/e/apps/MainActivity.kt @@ -139,11 +139,6 @@ class MainActivity : AppCompatActivity() { ) } else if (exception != null) { Timber.e(exception, "Login failed! message: ${exception?.localizedMessage}") - ApplicationDialogFragment( - title = getString(R.string.sign_in_failed_title), - message = getString(R.string.sign_in_failed_desc), - positiveButtonText = getString(R.string.ok) - ).show(supportFragmentManager, TAG) } } } diff --git a/app/src/main/java/foundation/e/apps/data/NetworkHandler.kt b/app/src/main/java/foundation/e/apps/data/NetworkHandler.kt new file mode 100644 index 0000000000000000000000000000000000000000..78985de5fb13bfdcf071fe6dcce939a5abcb6ebe --- /dev/null +++ b/app/src/main/java/foundation/e/apps/data/NetworkHandler.kt @@ -0,0 +1,73 @@ +/* + * Copyright MURENA SAS 2023 + * Apps Quickly and easily install Android apps onto your device! + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package foundation.e.apps.data + +import foundation.e.apps.data.gplay.utils.GPlayHttpClient +import foundation.e.apps.data.gplay.utils.GplayHttpRequestException +import foundation.e.apps.data.login.exceptions.GPlayException +import java.net.SocketTimeoutException + +private const val TIMEOUT = "Timeout" +private const val UNKNOWN = "Unknown" +private const val STATUS = "Status:" +private const val ERROR_GPLAY_API = "Gplay api has faced error!" + +suspend fun handleNetworkResult(call: suspend () -> T): ResultSupreme { + return try { + ResultSupreme.Success(call()) + } catch (e: SocketTimeoutException) { + handleSocketTimeoutException(e) + } catch (e: GplayHttpRequestException) { + resultSupremeGplayHttpRequestException(e) + } catch (e: Exception) { + handleOthersException(e) + } +} + +private fun handleSocketTimeoutException(e: SocketTimeoutException): ResultSupreme.Timeout { + val message = extractErrorMessage(e) + val resultTimeout = ResultSupreme.Timeout(exception = e) + resultTimeout.message = message + return resultTimeout +} + +private fun resultSupremeGplayHttpRequestException(e: GplayHttpRequestException): ResultSupreme { + val message = extractErrorMessage(e) + val exception = GPlayException(e.status == GPlayHttpClient.STATUS_CODE_TIMEOUT, message) + + return if (exception.isTimeout) { + ResultSupreme.Timeout(exception = exception) + } else { + ResultSupreme.Error(message, exception) + } +} + +private fun handleOthersException(e: Exception): ResultSupreme.Error { + val message = extractErrorMessage(e) + return ResultSupreme.Error(message, e) +} + +private fun extractErrorMessage(e: Exception): String { + val status = when (e) { + is GplayHttpRequestException -> e.status.toString() + is SocketTimeoutException -> TIMEOUT + else -> UNKNOWN + } + return (e.localizedMessage?.ifBlank { ERROR_GPLAY_API } ?: ERROR_GPLAY_API) + " $STATUS $status" +} diff --git a/app/src/main/java/foundation/e/apps/data/ResultSupreme.kt b/app/src/main/java/foundation/e/apps/data/ResultSupreme.kt index 14695702f15924b607ee449c8f66958bbe95ddad..a7a773f60ae33131d12c9ec6f68cae8adda48356 100644 --- a/app/src/main/java/foundation/e/apps/data/ResultSupreme.kt +++ b/app/src/main/java/foundation/e/apps/data/ResultSupreme.kt @@ -20,6 +20,8 @@ package foundation.e.apps.data import foundation.e.apps.data.enums.ResultStatus import java.util.concurrent.TimeoutException +private const val UNKNOWN_ERROR = "Unknown error!" + /** * Another implementation of Result class. * This removes the use of [ResultStatus] class for different status. @@ -52,10 +54,12 @@ sealed class ResultSupreme { * Example can be an empty list. * @param exception Optional exception from try-catch block. */ - class Timeout(data: T, exception: Exception = TimeoutException()) : + class Timeout(data: T? = null, exception: Exception = TimeoutException()) : ResultSupreme() { init { - setData(data) + data?.let { + setData(it) + } this.exception = exception } } @@ -119,6 +123,16 @@ sealed class ResultSupreme { this.data = data } + fun getResultStatus(): ResultStatus { + return when (this) { + is Success -> ResultStatus.OK + is Timeout -> ResultStatus.TIMEOUT + else -> ResultStatus.UNKNOWN.apply { + message = this@ResultSupreme.exception?.localizedMessage ?: UNKNOWN_ERROR + } + } + } + companion object { /** diff --git a/app/src/main/java/foundation/e/apps/data/cleanapk/RetrofitModule.kt b/app/src/main/java/foundation/e/apps/data/cleanapk/RetrofitModule.kt index 43a505137c32b44c1c88c655cf7a8c55faf04e19..c924f27c7ad5412351e3f3f40ce9b58b530ddb12 100644 --- a/app/src/main/java/foundation/e/apps/data/cleanapk/RetrofitModule.kt +++ b/app/src/main/java/foundation/e/apps/data/cleanapk/RetrofitModule.kt @@ -48,6 +48,7 @@ import retrofit2.converter.moshi.MoshiConverterFactory import timber.log.Timber import java.net.ConnectException import java.util.Locale +import java.util.concurrent.TimeUnit import javax.inject.Named import javax.inject.Singleton @@ -55,6 +56,8 @@ import javax.inject.Singleton @InstallIn(SingletonComponent::class) object RetrofitModule { + private const val HTTP_TIMEOUT_IN_SECOND = 10L + /** * Provides an instance of Retrofit to work with CleanAPK API * @return instance of [CleanApkRetrofit] @@ -208,6 +211,7 @@ object RetrofitModule { fun provideOkHttpClient(cache: Cache, interceptor: Interceptor): OkHttpClient { return OkHttpClient.Builder() .addInterceptor(interceptor) + .callTimeout(HTTP_TIMEOUT_IN_SECOND, TimeUnit.SECONDS) .cache(cache) .build() } diff --git a/app/src/main/java/foundation/e/apps/data/fused/FusedApiImpl.kt b/app/src/main/java/foundation/e/apps/data/fused/FusedApiImpl.kt index cb398a78c124c0e8ff6e03156570ac61a1ac0aae..e08bb6deda41e6b4cf580bedfabd29f15180e6e7 100644 --- a/app/src/main/java/foundation/e/apps/data/fused/FusedApiImpl.kt +++ b/app/src/main/java/foundation/e/apps/data/fused/FusedApiImpl.kt @@ -22,7 +22,6 @@ import android.content.Context import android.text.format.Formatter import androidx.lifecycle.LiveData import androidx.lifecycle.liveData -import androidx.lifecycle.map import com.aurora.gplayapi.Constants import com.aurora.gplayapi.SearchSuggestEntry import com.aurora.gplayapi.data.models.App @@ -63,8 +62,7 @@ import foundation.e.apps.data.fused.utils.CategoryType import foundation.e.apps.data.fused.utils.CategoryUtils import foundation.e.apps.data.fusedDownload.models.FusedDownload import foundation.e.apps.data.gplay.GplayStoreRepository -import foundation.e.apps.data.gplay.utils.GplayHttpRequestException -import foundation.e.apps.data.login.exceptions.GPlayException +import foundation.e.apps.data.handleNetworkResult import foundation.e.apps.data.preference.PreferenceManagerModule import foundation.e.apps.install.pkg.PWAManagerModule import foundation.e.apps.install.pkg.PkgManagerModule @@ -73,11 +71,9 @@ import kotlinx.coroutines.Deferred import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.flow.map import kotlinx.coroutines.withTimeout import retrofit2.Response import timber.log.Timber -import java.net.SocketTimeoutException import javax.inject.Inject import javax.inject.Named import javax.inject.Singleton @@ -100,8 +96,6 @@ class FusedApiImpl @Inject constructor( private const val CATEGORY_TITLE_REPLACEABLE_CONJUNCTION = "&" private const val CATEGORY_OPEN_GAMES_ID = "game_open_games" private const val CATEGORY_OPEN_GAMES_TITLE = "Open games" - private const val ERROR_GPLAY_SEARCH = "Gplay search has failed!" - private const val ERROR_GPLAY_SOURCE_NOT_SELECTED = "Gplay apps are not selected!" } /** @@ -167,29 +161,32 @@ class FusedApiImpl @Inject constructor( authData: AuthData, ): ResultSupreme> { - val apiStatus = when (source) { - Source.GPLAY -> runCodeWithTimeout({ + val result = when (source) { + Source.GPLAY -> handleNetworkResult> { priorList.addAll(fetchGPlayHome(authData)) - }) + priorList + } - Source.OPEN -> runCodeWithTimeout({ + Source.OPEN -> handleNetworkResult { val response = (cleanApkAppsRepository.getHomeScreenData() as Response).body() response?.home?.let { priorList.addAll(generateCleanAPKHome(it, APP_TYPE_OPEN)) } - }) + priorList + } - Source.PWA -> runCodeWithTimeout({ + Source.PWA -> handleNetworkResult { val response = (cleanApkPWARepository.getHomeScreenData() as Response).body() response?.home?.let { priorList.addAll(generateCleanAPKHome(it, APP_TYPE_PWA)) } - }) + priorList + } } - setHomeErrorMessage(apiStatus, source) + setHomeErrorMessage(result.getResultStatus(), source) priorList.sortByDescending { when (it.source) { APP_TYPE_OPEN -> 2 @@ -197,7 +194,7 @@ class FusedApiImpl @Inject constructor( else -> 3 } } - return ResultSupreme.create(apiStatus, priorList) + return ResultSupreme.create(result.getResultStatus(), priorList) } private fun setHomeErrorMessage(apiStatus: ResultStatus, source: Source) { @@ -289,7 +286,7 @@ class FusedApiImpl @Inject constructor( packageSpecificResults: ArrayList ): ResultSupreme, Boolean>> { val pwaApps: MutableList = mutableListOf() - val status = runCodeWithTimeout({ + val result = handleNetworkResult { val apps = cleanApkPWARepository.getSearchResult(query).body()?.apps apps?.apply { @@ -297,14 +294,14 @@ class FusedApiImpl @Inject constructor( pwaApps.addAll(this) } } - }) + } - if (pwaApps.isNotEmpty() || status != ResultStatus.OK) { + if (pwaApps.isNotEmpty() || result.getResultStatus() != ResultStatus.OK) { searchResult.addAll(pwaApps) } return ResultSupreme.create( - status, + result.getResultStatus(), Pair( filterWithKeywordSearch( searchResult, @@ -322,16 +319,17 @@ class FusedApiImpl @Inject constructor( searchResult: MutableList, packageSpecificResults: ArrayList ): ResultSupreme, Boolean>> { - val status = runCodeWithTimeout({ + val result = handleNetworkResult { cleanApkResults.addAll(getCleanAPKSearchResults(query)) - }) + cleanApkResults + } if (cleanApkResults.isNotEmpty()) { searchResult.addAll(cleanApkResults) } return ResultSupreme.create( - status, + result.getResultStatus(), Pair( filterWithKeywordSearch( searchResult, @@ -351,7 +349,7 @@ class FusedApiImpl @Inject constructor( var gplayPackageResult: FusedApp? = null var cleanapkPackageResult: FusedApp? = null - val status = runCodeWithTimeout({ + val result = handleNetworkResult { if (preferenceManagerModule.isGplaySelected()) { gplayPackageResult = getGplayPackagResult(query, authData) } @@ -359,7 +357,7 @@ class FusedApiImpl @Inject constructor( if (preferenceManagerModule.isOpenSourceSelected()) { cleanapkPackageResult = getCleanApkPackageResult(query) } - }) + } /* * Currently only show open source package result if exists in both fdroid and gplay. @@ -378,10 +376,13 @@ class FusedApiImpl @Inject constructor( * If there was a timeout, return it and don't try to fetch anything else. * Also send true in the pair to signal more results being loaded. */ - if (status != ResultStatus.OK) { - return ResultSupreme.create(status, Pair(packageSpecificResults, false)) + if (result.getResultStatus() != ResultStatus.OK) { + return ResultSupreme.create( + result.getResultStatus(), + Pair(packageSpecificResults, false) + ) } - return ResultSupreme.create(status, Pair(packageSpecificResults, true)) + return ResultSupreme.create(result.getResultStatus(), Pair(packageSpecificResults, true)) } /* @@ -446,7 +447,7 @@ class FusedApiImpl @Inject constructor( */ private suspend fun getCleanapkSearchResult(packageName: String): ResultSupreme { var fusedApp = FusedApp() - val status = runCodeWithTimeout({ + val result = handleNetworkResult { val result = cleanApkAppsRepository.getSearchResult( packageName, "package_name" @@ -455,15 +456,15 @@ class FusedApiImpl @Inject constructor( if (result?.apps?.isNotEmpty() == true && result.numberOfResults == 1) { fusedApp = result.apps[0] } - }) - return ResultSupreme.create(status, fusedApp) + } + return ResultSupreme.create(result.getResultStatus(), fusedApp) } override suspend fun getSearchSuggestions(query: String): List { var searchSuggesions = listOf() - runCodeWithTimeout({ + handleNetworkResult { searchSuggesions = gplayRepository.getSearchSuggestions(query) - }) + } return searchSuggesions } @@ -523,7 +524,7 @@ class FusedApiImpl @Inject constructor( override suspend fun getPWAApps(category: String): ResultSupreme, String>> { val list = mutableListOf() - val status = runCodeWithTimeout({ + val result = handleNetworkResult { val response = getPWAAppsResponse(category) response?.apps?.forEach { it.updateStatus() @@ -531,13 +532,13 @@ class FusedApiImpl @Inject constructor( it.updateFilterLevel(null) list.add(it) } - }) - return ResultSupreme.create(status, Pair(list, "")) + } + return ResultSupreme.create(result.getResultStatus(), Pair(list, "")) } override suspend fun getOpenSourceApps(category: String): ResultSupreme, String>> { val list = mutableListOf() - val status = runCodeWithTimeout({ + val result = handleNetworkResult { val response = getOpenSourceAppsResponse(category) response?.apps?.forEach { it.updateStatus() @@ -545,8 +546,8 @@ class FusedApiImpl @Inject constructor( it.updateFilterLevel(null) list.add(it) } - }) - return ResultSupreme.create(status, Pair(list, "")) + } + return ResultSupreme.create(result.getResultStatus(), Pair(list, "")) } /* @@ -557,7 +558,7 @@ class FusedApiImpl @Inject constructor( */ override suspend fun getCleanapkAppDetails(packageName: String): Pair { var fusedApp = FusedApp() - val status = runCodeWithTimeout({ + val result = handleNetworkResult { val result = cleanApkAppsRepository.getSearchResult( packageName, "package_name" @@ -569,8 +570,8 @@ class FusedApiImpl @Inject constructor( ?: FusedApp() } fusedApp.updateFilterLevel(null) - }) - return Pair(fusedApp, status) + } + return Pair(fusedApp, result.getResultStatus()) } override suspend fun getApplicationDetails( @@ -614,7 +615,7 @@ class FusedApiImpl @Inject constructor( * i.e. check timeout for individual package query. */ for (packageName in packageNameList) { - status = runCodeWithTimeout({ + val result = handleNetworkResult { cleanApkAppsRepository.getSearchResult( packageName, "package_name" @@ -627,7 +628,9 @@ class FusedApiImpl @Inject constructor( ) } } - }) + } + + status = result.getResultStatus() /* * If status is not ok, immediately return. @@ -653,7 +656,7 @@ class FusedApiImpl @Inject constructor( /* * Old code moved from getApplicationDetails() */ - val status = runCodeWithTimeout({ + val result = handleNetworkResult { gplayRepository.getAppsDetails(packageNameList).forEach { app -> /* * Some apps are restricted to locations. Example "com.skype.m2". @@ -670,9 +673,9 @@ class FusedApiImpl @Inject constructor( ) } } - }) + } - return Pair(fusedAppList, status) + return Pair(fusedAppList, result.getResultStatus()) } /** @@ -691,7 +694,7 @@ class FusedApiImpl @Inject constructor( appList: List, ): ResultSupreme> { val filteredFusedApps = mutableListOf() - val status = runCodeWithTimeout({ + return handleNetworkResult { appList.forEach { val filter = getAppFilterLevel(it, authData) if (filter.isUnFiltered()) { @@ -702,9 +705,8 @@ class FusedApiImpl @Inject constructor( ) } } - }) - - return ResultSupreme.create(status, filteredFusedApps) + filteredFusedApps + } } /** @@ -783,7 +785,7 @@ class FusedApiImpl @Inject constructor( var response: FusedApp? = null - val status = runCodeWithTimeout({ + val result = handleNetworkResult { response = if (origin == Origin.CLEANAPK) { (cleanApkAppsRepository.getAppDetails(id) as Response).body()?.app } else { @@ -796,9 +798,10 @@ class FusedApiImpl @Inject constructor( it.updateSource() it.updateFilterLevel(authData) } - }) + response + } - return Pair(response ?: FusedApp(), status) + return Pair(result.data ?: FusedApp(), result.getResultStatus()) } /* @@ -836,9 +839,9 @@ class FusedApiImpl @Inject constructor( val gplayCategoryResult = fetchGplayCategories( type, ) - categoriesList.addAll(gplayCategoryResult.second) - apiStatus = gplayCategoryResult.first - errorApplicationCategory = gplayCategoryResult.third + categoriesList.addAll(gplayCategoryResult.data ?: listOf()) + apiStatus = gplayCategoryResult.getResultStatus() + errorApplicationCategory = APP_TYPE_ANY } return Pair(apiStatus, errorApplicationCategory) @@ -846,34 +849,25 @@ class FusedApiImpl @Inject constructor( private suspend fun fetchGplayCategories( type: CategoryType, - ): Triple, String> { - var errorApplicationCategory = "" - var apiStatus = ResultStatus.OK + ): ResultSupreme> { val categoryList = mutableListOf() - runCodeWithTimeout({ + + return handleNetworkResult { val playResponse = gplayRepository.getCategories(type).map { app -> val category = app.transformToFusedCategory() updateCategoryDrawable(category) category } categoryList.addAll(playResponse) - }, { - errorApplicationCategory = APP_TYPE_ANY - apiStatus = ResultStatus.TIMEOUT - }, { - errorApplicationCategory = APP_TYPE_ANY - apiStatus = ResultStatus.UNKNOWN - }) - return Triple(apiStatus, categoryList, errorApplicationCategory) + categoryList + } } private suspend fun fetchPWACategories( type: CategoryType, ): Triple, String> { - var errorApplicationCategory = "" - var apiStatus: ResultStatus = ResultStatus.OK val fusedCategoriesList = mutableListOf() - runCodeWithTimeout({ + val result = handleNetworkResult { getPWAsCategories()?.let { fusedCategoriesList.addAll( getFusedCategoryBasedOnCategoryType( @@ -881,23 +875,16 @@ class FusedApiImpl @Inject constructor( ) ) } - }, { - errorApplicationCategory = APP_TYPE_PWA - apiStatus = ResultStatus.TIMEOUT - }, { - errorApplicationCategory = APP_TYPE_PWA - apiStatus = ResultStatus.UNKNOWN - }) - return Triple(apiStatus, fusedCategoriesList, errorApplicationCategory) + } + + return Triple(result.getResultStatus(), fusedCategoriesList, APP_TYPE_PWA) } private suspend fun fetchOpenSourceCategories( type: CategoryType, ): Triple, String> { - var errorApplicationCategory = "" - var apiStatus: ResultStatus = ResultStatus.OK val fusedCategoryList = mutableListOf() - runCodeWithTimeout({ + val result = handleNetworkResult { getOpenSourceCategories()?.let { fusedCategoryList.addAll( getFusedCategoryBasedOnCategoryType( @@ -907,14 +894,9 @@ class FusedApiImpl @Inject constructor( ) ) } - }, { - errorApplicationCategory = APP_TYPE_OPEN - apiStatus = ResultStatus.TIMEOUT - }, { - errorApplicationCategory = APP_TYPE_OPEN - apiStatus = ResultStatus.UNKNOWN - }) - return Triple(apiStatus, fusedCategoryList, errorApplicationCategory) + } + + return Triple(result.getResultStatus(), fusedCategoryList, APP_TYPE_OPEN) } /** @@ -956,9 +938,8 @@ class FusedApiImpl @Inject constructor( } private fun getCategoryIconName(category: FusedCategory): String { - var categoryTitle = if (category.tag.getOperationalTag() - .contentEquals(AppTag.GPlay().getOperationalTag()) - ) category.id else category.title + var categoryTitle = if (category.tag.getOperationalTag().contentEquals(AppTag.GPlay().getOperationalTag())) + category.id else category.title if (categoryTitle.contains(CATEGORY_TITLE_REPLACEABLE_CONJUNCTION)) { categoryTitle = categoryTitle.replace(CATEGORY_TITLE_REPLACEABLE_CONJUNCTION, "and") @@ -1082,12 +1063,12 @@ class FusedApiImpl @Inject constructor( query: String, nextPageSubBundle: Set? ): GplaySearchResult { - try { + return handleNetworkResult { val searchResults = gplayRepository.getSearchResult(query, nextPageSubBundle?.toMutableSet()) if (!preferenceManagerModule.isGplaySelected()) { - return ResultSupreme.Error(ERROR_GPLAY_SOURCE_NOT_SELECTED) + return@handleNetworkResult Pair(listOf(), setOf()) } val fusedAppList = @@ -1097,20 +1078,7 @@ class FusedApiImpl @Inject constructor( fusedAppList.add(FusedApp(isPlaceHolder = true)) } - return ResultSupreme.Success(Pair(fusedAppList.toList(), searchResults.second.toSet())) - } catch (e: GplayHttpRequestException) { - val message = ( - e.localizedMessage?.ifBlank { ERROR_GPLAY_SEARCH } - ?: ERROR_GPLAY_SEARCH - ) + "Status: ${e.status}" - - val exception = GPlayException(e.status == 408, message) - return ResultSupreme.Error(message, exception) - } catch (e: Exception) { - val exception = - GPlayException(e is SocketTimeoutException, e.localizedMessage) - - return ResultSupreme.Error(e.localizedMessage ?: "", exception) + return@handleNetworkResult Pair(fusedAppList.toList(), searchResults.second.toSet()) } } @@ -1416,7 +1384,7 @@ class FusedApiImpl @Inject constructor( var fusedAppList: MutableList = mutableListOf() var nextPageUrl = "" - val status = runCodeWithTimeout({ + return handleNetworkResult { val streamCluster = gplayRepository.getAppsByCategory(category, pageUrl) as StreamCluster @@ -1429,8 +1397,7 @@ class FusedApiImpl @Inject constructor( if (!nextPageUrl.isNullOrEmpty()) { fusedAppList.add(FusedApp(isPlaceHolder = true)) } - }) - - return ResultSupreme.create(status, Pair(fusedAppList, nextPageUrl)) + Pair(fusedAppList, nextPageUrl) + } } } diff --git a/app/src/main/java/foundation/e/apps/data/gplay/GplayStoreRepositoryImpl.kt b/app/src/main/java/foundation/e/apps/data/gplay/GplayStoreRepositoryImpl.kt index 19461db72783b10456324a8ffd9187190530eb2b..383f8e7c6e43a3914944d9e2708fbb45bfbc0bfe 100644 --- a/app/src/main/java/foundation/e/apps/data/gplay/GplayStoreRepositoryImpl.kt +++ b/app/src/main/java/foundation/e/apps/data/gplay/GplayStoreRepositoryImpl.kt @@ -52,7 +52,7 @@ class GplayStoreRepositoryImpl @Inject constructor( override suspend fun getHomeScreenData(): Any { val homeScreenData = mutableMapOf>() val homeElements = createTopChartElements() - val authData = loginSourceRepository.gplayAuth ?: return homeScreenData + val authData = loginSourceRepository.gplayAuth!! homeElements.forEach { val chart = it.value.keys.iterator().next() @@ -77,7 +77,7 @@ class GplayStoreRepositoryImpl @Inject constructor( query: String, subBundle: MutableSet? ): Pair, MutableSet> { - var authData = loginSourceRepository.gplayAuth ?: return Pair(emptyList(), mutableSetOf()) + var authData = loginSourceRepository.gplayAuth!! val searchHelper = SearchHelper(authData).using(gPlayHttpClient) @@ -102,7 +102,7 @@ class GplayStoreRepositoryImpl @Inject constructor( } override suspend fun getSearchSuggestions(query: String): List { - val authData = loginSourceRepository.gplayAuth ?: return listOf() + val authData = loginSourceRepository.gplayAuth!! val searchData = mutableListOf() withContext(Dispatchers.IO) { @@ -113,7 +113,7 @@ class GplayStoreRepositoryImpl @Inject constructor( } override suspend fun getAppsByCategory(category: String, pageUrl: String?): StreamCluster { - val authData = loginSourceRepository.gplayAuth ?: return StreamCluster() + val authData = loginSourceRepository.gplayAuth!! val subCategoryHelper = CategoryAppsHelper(authData).using(gPlayHttpClient) @@ -131,7 +131,7 @@ class GplayStoreRepositoryImpl @Inject constructor( return categoryList } - val authData = loginSourceRepository.gplayAuth ?: return categoryList + val authData = loginSourceRepository.gplayAuth!! withContext(Dispatchers.IO) { val categoryHelper = CategoryHelper(authData).using(gPlayHttpClient) @@ -142,7 +142,7 @@ class GplayStoreRepositoryImpl @Inject constructor( override suspend fun getAppDetails(packageNameOrId: String): App? { var appDetails: App? - val authData = loginSourceRepository.gplayAuth ?: return null + val authData = loginSourceRepository.gplayAuth!! withContext(Dispatchers.IO) { val appDetailsHelper = AppDetailsHelper(authData).using(gPlayHttpClient) @@ -153,7 +153,7 @@ class GplayStoreRepositoryImpl @Inject constructor( override suspend fun getAppsDetails(packageNamesOrIds: List): List { val appDetailsList = mutableListOf() - val authData = loginSourceRepository.gplayAuth ?: return appDetailsList + val authData = loginSourceRepository.gplayAuth!! withContext(Dispatchers.IO) { val appDetailsHelper = AppDetailsHelper(authData).using(gPlayHttpClient) @@ -185,7 +185,7 @@ class GplayStoreRepositoryImpl @Inject constructor( offerType: Int ): List { val downloadData = mutableListOf() - val authData = loginSourceRepository.gplayAuth ?: return downloadData + val authData = loginSourceRepository.gplayAuth!! withContext(Dispatchers.IO) { val version = versionCode?.let { it as Int } ?: -1 @@ -202,7 +202,7 @@ class GplayStoreRepositoryImpl @Inject constructor( offerType: Int ): List { val downloadData = mutableListOf() - val authData = loginSourceRepository.gplayAuth ?: return downloadData + val authData = loginSourceRepository.gplayAuth!! withContext(Dispatchers.IO) { val purchaseHelper = PurchaseHelper(authData).using(gPlayHttpClient) diff --git a/app/src/main/java/foundation/e/apps/data/gplay/utils/GPlayHttpClient.kt b/app/src/main/java/foundation/e/apps/data/gplay/utils/GPlayHttpClient.kt index b502cd75672054783c1c4d63c6a76f4889ba570a..2bfd5578aa5ff7dc8d8ef530a04dd50c0f1c7438 100644 --- a/app/src/main/java/foundation/e/apps/data/gplay/utils/GPlayHttpClient.kt +++ b/app/src/main/java/foundation/e/apps/data/gplay/utils/GPlayHttpClient.kt @@ -19,6 +19,7 @@ package foundation.e.apps.data.gplay.utils +import androidx.annotation.VisibleForTesting import com.aurora.gplayapi.data.models.PlayResponse import com.aurora.gplayapi.network.IHttpClient import foundation.e.apps.data.login.AuthObject @@ -40,12 +41,11 @@ import okhttp3.Response 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( - private val cache: Cache, + private val cache: Cache, ) : IHttpClient { private val POST = "POST" @@ -56,11 +56,14 @@ class GPlayHttpClient @Inject constructor( private const val HTTP_TIMEOUT_IN_SECOND = 10L private const val SEARCH = "search" private const val SEARCH_SUGGEST = "searchSuggest" + private const val STATUS_CODE_OK = 200 private const val STATUS_CODE_UNAUTHORIZED = 401 private const val STATUS_CODE_TOO_MANY_REQUESTS = 429 + const val STATUS_CODE_TIMEOUT = 408 } - private val okHttpClient = OkHttpClient().newBuilder() + @VisibleForTesting + var okHttpClient = OkHttpClient().newBuilder() .retryOnConnectionFailure(false) .callTimeout(HTTP_TIMEOUT_IN_SECOND, TimeUnit.SECONDS) .followRedirects(true) @@ -163,31 +166,16 @@ class GPlayHttpClient @Inject constructor( val call = okHttpClient.newCall(request) response = call.execute() buildPlayResponse(response) + } catch (e: GplayHttpRequestException) { + throw e } catch (e: Exception) { - // TODO: exception will be thrown for all apis when all gplay api implementation - // will handle the exceptions. this will be done in following issue. - // Issue: https://gitlab.e.foundation/e/os/backlog/-/issues/1483 - if (request.url.toString().contains(SEARCH)) { - throw e - } - - when (e) { - is UnknownHostException, - is SocketTimeoutException -> handleExceptionOnGooglePlayRequest(e) - else -> handleExceptionOnGooglePlayRequest(e) - } + val status = if (e is SocketTimeoutException) STATUS_CODE_TIMEOUT else -1 + throw GplayHttpRequestException(status, e.localizedMessage ?: "") } finally { response?.close() } } - private fun handleExceptionOnGooglePlayRequest(e: Exception): PlayResponse { - Timber.e("processRequest: ${e.localizedMessage}") - return PlayResponse().apply { - errorString = "${this@GPlayHttpClient::class.java.simpleName}: ${e.localizedMessage}" - } - } - private fun buildUrl(url: String, params: Map): HttpUrl { val urlBuilder = url.toHttpUrl().newBuilder() params.forEach { @@ -221,10 +209,7 @@ class GPlayHttpClient @Inject constructor( } } - // TODO: exception will be thrown for all apis when all gplay api implementation - // will handle the exceptions. this will be done in following issue. - // Issue: https://gitlab.e.foundation/e/os/backlog/-/issues/1483 - if (response.request.url.toString().contains(SEARCH) && code != 200) { + if (code !in listOf(STATUS_CODE_OK, STATUS_CODE_UNAUTHORIZED)) { throw GplayHttpRequestException(code, response.message) } diff --git a/app/src/main/java/foundation/e/apps/data/login/LoginCommon.kt b/app/src/main/java/foundation/e/apps/data/login/LoginCommon.kt index 6f3646153fe5f95eb612fe6af00d4692df8e0d51..cc38464e217caff6daac4c85b38fa194d8e58700 100644 --- a/app/src/main/java/foundation/e/apps/data/login/LoginCommon.kt +++ b/app/src/main/java/foundation/e/apps/data/login/LoginCommon.kt @@ -36,6 +36,10 @@ class LoginCommon @Inject constructor( loginDataStore.saveUserType(user) } + fun getUserType(): User { + return loginDataStore.getUserType() + } + suspend fun saveGoogleLogin(email: String, oauth: String) { loginDataStore.saveGoogleLogin(email, oauth) } diff --git a/app/src/main/java/foundation/e/apps/data/login/LoginSourceRepository.kt b/app/src/main/java/foundation/e/apps/data/login/LoginSourceRepository.kt index 6cbf9999810088217b171e77bf60b2b0564ed520..fea7939b6a31e8cbf2353b172add38ed9bac312b 100644 --- a/app/src/main/java/foundation/e/apps/data/login/LoginSourceRepository.kt +++ b/app/src/main/java/foundation/e/apps/data/login/LoginSourceRepository.kt @@ -20,6 +20,7 @@ package foundation.e.apps.data.login import com.aurora.gplayapi.data.models.AuthData import foundation.e.apps.data.ResultSupreme import foundation.e.apps.data.enums.User +import foundation.e.apps.data.login.exceptions.GPlayLoginException import javax.inject.Inject import javax.inject.Singleton @@ -31,6 +32,8 @@ class LoginSourceRepository @Inject constructor( ) { var gplayAuth: AuthData? = null + get() = field ?: throw GPlayLoginException(false, "AuthData is not available!", getUserType()) + suspend fun getAuthObjects(clearAuthTypes: List = listOf()): List { val authObjectsLocal = ArrayList() @@ -40,10 +43,13 @@ class LoginSourceRepository @Inject constructor( if (source::class.java.simpleName in clearAuthTypes) { source.clearSavedAuth() } - if (source is LoginSourceGPlay) { - gplayAuth = source.getAuthObject().result.data + + val authObject = source.getAuthObject() + authObjectsLocal.add(authObject) + + if (authObject is AuthObject.GPlayAuth) { + gplayAuth = authObject.result.data } - authObjectsLocal.add(source.getAuthObject()) } return authObjectsLocal @@ -71,4 +77,8 @@ class LoginSourceRepository @Inject constructor( this.gplayAuth = validateAuthData.data return validateAuthData } + + private fun getUserType(): User { + return loginCommon.getUserType() + } } diff --git a/app/src/main/java/foundation/e/apps/data/login/api/LoginApiRepository.kt b/app/src/main/java/foundation/e/apps/data/login/api/LoginApiRepository.kt index 5d97b64870aa92b261af2e29cf8470a2bc47d110..b2fbd026c9919c4b62a0e97c0736fa367b786048 100644 --- a/app/src/main/java/foundation/e/apps/data/login/api/LoginApiRepository.kt +++ b/app/src/main/java/foundation/e/apps/data/login/api/LoginApiRepository.kt @@ -19,13 +19,11 @@ package foundation.e.apps.data.login.api import com.aurora.gplayapi.data.models.AuthData import com.aurora.gplayapi.data.models.PlayResponse -import foundation.e.apps.data.Constants.timeoutDurationInMillis import foundation.e.apps.data.ResultSupreme import foundation.e.apps.data.enums.User import foundation.e.apps.data.gplay.utils.AC2DMUtil +import foundation.e.apps.data.handleNetworkResult import foundation.e.apps.data.login.exceptions.GPlayLoginException -import kotlinx.coroutines.TimeoutCancellationException -import kotlinx.coroutines.withTimeout import java.util.Locale /** @@ -52,9 +50,9 @@ class LoginApiRepository constructor( * else blank for Anonymous login. */ suspend fun fetchAuthData(email: String, aasToken: String, locale: Locale): ResultSupreme { - val result = runCodeWithTimeout({ + val result = handleNetworkResult { gPlayLoginInterface.fetchAuthData(email, aasToken) - }) + } return result.apply { this.data?.locale = locale this.exception = when (result) { @@ -76,13 +74,13 @@ class LoginApiRepository constructor( */ suspend fun login(authData: AuthData): ResultSupreme { var response = PlayResponse() - val result = runCodeWithTimeout({ + val result = handleNetworkResult { 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) @@ -109,8 +107,8 @@ class LoginApiRepository constructor( googleLoginApi: GoogleLoginApi, email: String, oauthToken: String - ): ResultSupreme { - val result = runCodeWithTimeout({ + ): ResultSupreme { + val result = handleNetworkResult { var aasToken = "" val response = googleLoginApi.getAC2DMResponse(email, oauthToken) var error = response.errorString @@ -129,7 +127,7 @@ class LoginApiRepository constructor( throw Exception(error) } aasToken - }) + } return result.apply { this.exception = when (result) { is ResultSupreme.Timeout -> GPlayLoginException(true, "GPlay API timeout", User.GOOGLE) @@ -138,26 +136,4 @@ class LoginApiRepository constructor( } } } - - /** - * Utility method to run a specified code block in a fixed amount of time. - */ - private suspend fun runCodeWithTimeout( - block: suspend () -> T, - timeoutBlock: (() -> T?)? = null, - exceptionBlock: ((e: Exception) -> 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(e), message = e.message ?: "") - } - } } diff --git a/app/src/main/java/foundation/e/apps/data/preference/DataStoreManager.kt b/app/src/main/java/foundation/e/apps/data/preference/DataStoreManager.kt index 4294505ae205925730fb6eb2c2d2cf011b5be550..8bd36cbfe1db8bcdd163960a7b4a0891b6de5c44 100644 --- a/app/src/main/java/foundation/e/apps/data/preference/DataStoreManager.kt +++ b/app/src/main/java/foundation/e/apps/data/preference/DataStoreManager.kt @@ -33,7 +33,7 @@ class DataStoreManager @Inject constructor() { fun getAuthData(): AuthData { val authDataJson = dataStoreModule.getAuthDataSync() - return gson.fromJson(authDataJson, AuthData::class.java) + return gson.fromJson(authDataJson, AuthData::class.java) ?: AuthData("", "") } fun getUserType(): User { diff --git a/app/src/main/java/foundation/e/apps/ui/applicationlist/ApplicationListViewModel.kt b/app/src/main/java/foundation/e/apps/ui/applicationlist/ApplicationListViewModel.kt index 79c8cc52d37bbb50296571b448cd0093d120a758..86be6867d85fb6e9970d9bbdec2faf078de10a32 100644 --- a/app/src/main/java/foundation/e/apps/ui/applicationlist/ApplicationListViewModel.kt +++ b/app/src/main/java/foundation/e/apps/ui/applicationlist/ApplicationListViewModel.kt @@ -42,9 +42,11 @@ class ApplicationListViewModel @Inject constructor( val appListLiveData: MutableLiveData>?> = MutableLiveData() - var isLoading = false + private var isLoading = false - var nextPageUrl: String? = null + private var nextPageUrl: String? = null + + private var currentAuthListObject: List? = null fun loadData( category: String, @@ -54,11 +56,18 @@ class ApplicationListViewModel @Inject constructor( ) { super.onLoadData(authObjectList, { successAuthList, _ -> - if (appListLiveData.value?.data?.isNotEmpty() == true) { + // if token is refreshed, then reset all data + if (currentAuthListObject != null && currentAuthListObject != authObjectList) { + appListLiveData.postValue(ResultSupreme.Success(emptyList())) + nextPageUrl = null + } + + if (appListLiveData.value?.data?.isNotEmpty() == true && currentAuthListObject == authObjectList) { appListLiveData.postValue(appListLiveData.value) return@onLoadData } + this.currentAuthListObject = authObjectList successAuthList.find { it is AuthObject.GPlayAuth }?.run { getList(category, result.data!! as AuthData, source) return@onLoadData @@ -163,18 +172,9 @@ class ApplicationListViewModel @Inject constructor( private fun appendAppList(it: Pair, String>): List? { val currentAppList = appListLiveData.value?.data?.toMutableList() currentAppList?.removeIf { item -> item.isPlaceHolder } - val appList = currentAppList?.plus(it.first) - return appList + return currentAppList?.plus(it.first) } - /** - * @return returns true if there is changes in data, otherwise false - */ - fun isAnyAppUpdated( - newFusedApps: List, - oldFusedApps: List - ) = fusedAPIRepository.isAnyFusedAppUpdated(newFusedApps, oldFusedApps) - fun hasAnyAppInstallStatusChanged(currentList: List) = fusedAPIRepository.isAnyAppInstallStatusChanged(currentList) } diff --git a/app/src/main/java/foundation/e/apps/ui/search/SearchViewModel.kt b/app/src/main/java/foundation/e/apps/ui/search/SearchViewModel.kt index 90e07f32bf5620d06ce6f2f7a8406ff1b8fb276d..a2bc710807f7e60f1d9d0e8e69d45bbf02b466b8 100644 --- a/app/src/main/java/foundation/e/apps/ui/search/SearchViewModel.kt +++ b/app/src/main/java/foundation/e/apps/ui/search/SearchViewModel.kt @@ -19,7 +19,6 @@ package foundation.e.apps.ui.search import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope import com.aurora.gplayapi.SearchSuggestEntry @@ -42,7 +41,6 @@ import timber.log.Timber import javax.inject.Inject import kotlin.coroutines.coroutineContext - @HiltViewModel class SearchViewModel @Inject constructor( private val fusedAPIRepository: FusedAPIRepository, @@ -159,8 +157,8 @@ class SearchViewModel @Inject constructor( val isFirstFetch = nextSubBundle == null nextSubBundle = gplaySearchResult.data?.second - //first page has less data, then fetch next page data without waiting for users' scroll - if (isFirstFetch) { + // first page has less data, then fetch next page data without waiting for users' scroll + if (isFirstFetch && gplaySearchResult.isSuccess()) { CoroutineScope(coroutineContext).launch { fetchGplayData(query) } diff --git a/app/src/test/java/foundation/e/apps/FusedApiImplTest.kt b/app/src/test/java/foundation/e/apps/fused/FusedApiImplTest.kt similarity index 99% rename from app/src/test/java/foundation/e/apps/FusedApiImplTest.kt rename to app/src/test/java/foundation/e/apps/fused/FusedApiImplTest.kt index 3096c444c0309461cf941da1d3d1209197c0deb6..816394481bf93a1dbfd262897aec913cbe474d64 100644 --- a/app/src/test/java/foundation/e/apps/FusedApiImplTest.kt +++ b/app/src/test/java/foundation/e/apps/fused/FusedApiImplTest.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package foundation.e.apps +package foundation.e.apps.fused import android.content.Context import android.text.format.Formatter @@ -25,6 +25,8 @@ import com.aurora.gplayapi.data.models.App import com.aurora.gplayapi.data.models.AuthData import com.aurora.gplayapi.data.models.Category import com.aurora.gplayapi.data.models.SearchBundle +import foundation.e.apps.FakePreferenceModule +import foundation.e.apps.R import foundation.e.apps.data.cleanapk.data.categories.Categories import foundation.e.apps.data.cleanapk.data.search.Search import foundation.e.apps.data.cleanapk.repositories.CleanApkRepository @@ -682,7 +684,7 @@ class FusedApiImplTest { Mockito.`when`( gPlayAPIRepository.getCategories(CategoryType.APPLICATION) - ).thenThrow(RuntimeException()) + ).thenThrow() val categoryListResponse = fusedAPIImpl.getCategoriesList(CategoryType.APPLICATION) diff --git a/app/src/test/java/foundation/e/apps/FusedApiRepositoryTest.kt b/app/src/test/java/foundation/e/apps/fused/FusedApiRepositoryTest.kt similarity index 98% rename from app/src/test/java/foundation/e/apps/FusedApiRepositoryTest.kt rename to app/src/test/java/foundation/e/apps/fused/FusedApiRepositoryTest.kt index 8e04ce98e89935f2a9afa77e22afd23f0523aab3..801ae4437c2ef778c07e7fd80d0c709c953040d8 100644 --- a/app/src/test/java/foundation/e/apps/FusedApiRepositoryTest.kt +++ b/app/src/test/java/foundation/e/apps/fused/FusedApiRepositoryTest.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package foundation.e.apps +package foundation.e.apps.fused import foundation.e.apps.data.fused.FusedAPIRepository import foundation.e.apps.data.fused.FusedApiImpl diff --git a/app/src/test/java/foundation/e/apps/gplay/GplyHttpClientTest.kt b/app/src/test/java/foundation/e/apps/gplay/GplyHttpClientTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..f906f6fb983e1c6d262a87fcf15dd520264da4b7 --- /dev/null +++ b/app/src/test/java/foundation/e/apps/gplay/GplyHttpClientTest.kt @@ -0,0 +1,135 @@ +/* + * Copyright MURENA SAS 2023 + * Apps Quickly and easily install Android apps onto your device! + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package foundation.e.apps.gplay + +import com.aurora.gplayapi.data.models.PlayResponse +import foundation.e.apps.data.gplay.utils.GPlayHttpClient +import foundation.e.apps.data.login.AuthObject +import foundation.e.apps.util.FakeCall +import foundation.e.apps.util.MainCoroutineRule +import foundation.e.apps.utils.SystemInfoProvider +import foundation.e.apps.utils.eventBus.AppEvent +import foundation.e.apps.utils.eventBus.EventBus +import io.mockk.every +import io.mockk.mockkObject +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import okhttp3.Cache +import okhttp3.OkHttpClient +import okhttp3.RequestBody.Companion.toRequestBody +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.mockito.Mock +import org.mockito.Mockito +import org.mockito.MockitoAnnotations +import org.mockito.kotlin.any +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class GplyHttpClientTest { + + @Mock + private lateinit var cache: Cache + + @Mock + private lateinit var okHttpClient: OkHttpClient + + private lateinit var call: FakeCall + + private lateinit var gPlayHttpClient: GPlayHttpClient + + @OptIn(ExperimentalCoroutinesApi::class) + @get:Rule + val coroutineTestRule = MainCoroutineRule() + + @Before + fun setup() { + MockitoAnnotations.openMocks(this) + gPlayHttpClient = GPlayHttpClient(cache) + gPlayHttpClient.okHttpClient = this.okHttpClient + call = FakeCall() + } + + @Test + fun testPostWithMapFailedWhenStatus401() = runTest { + initMocks() + val response = gPlayHttpClient.post("http://abc.abc", mapOf(), mapOf()) + assertResponse(response) + } + + @Test + fun testPostWithRequestBodyFailedWhenStatus401() = runTest { + initMocks() + val response = gPlayHttpClient.post("http://abc.abc", mapOf(), "".toRequestBody()) + assertResponse(response) + } + + @Test + fun testPostWithByteArrayFailedWhenStatus401() = runTest { + initMocks() + val response = gPlayHttpClient.post("http://abc.abc", mapOf(), "".toByteArray()) + assertResponse(response) + } + + @Test + fun testGetWithoutParamsFailedWhenStatus401() = runTest { + initMocks() + val response = gPlayHttpClient.get(FakeCall.FAKE_URL, mapOf()) + assertResponse(response) + } + + @Test + fun testGetWithStringParamsFailedWhenStatus401() = runTest { + initMocks() + val response = gPlayHttpClient.get(FakeCall.FAKE_URL, mapOf(), "") + assertResponse(response) + } + + @Test + fun testGetWithMapParamsFailedWhenStatus401() = runTest { + initMocks() + val response = gPlayHttpClient.get(FakeCall.FAKE_URL, mapOf(), mapOf()) + assertResponse(response) + } + + @Test + fun testPostAuthFailedWhenStatus401() = runTest { + initMocks() + val response = gPlayHttpClient.postAuth("http://abc.abc", "".toByteArray()) + assertResponse(response) + } + + private fun initMocks() { + call.willThrow401 = true + mockkObject(SystemInfoProvider) + every { SystemInfoProvider.getAppBuildInfo() } returns "" + Mockito.`when`(okHttpClient.newCall(any())).thenReturn(call) + } + private suspend fun assertResponse(response: PlayResponse) { + assertFalse(response.isSuccessful) + assertTrue(response.code == 401) + val event = EventBus.events.first() + assertTrue(event is AppEvent.InvalidAuthEvent) + assertTrue(event.data is String) + assertTrue(event.data == AuthObject.GPlayAuth::class.java.simpleName) + } +} diff --git a/app/src/test/java/foundation/e/apps/login/LoginViewModelTest.kt b/app/src/test/java/foundation/e/apps/login/LoginViewModelTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..01521a1fff76162299a3a1527e49cdab8f48db24 --- /dev/null +++ b/app/src/test/java/foundation/e/apps/login/LoginViewModelTest.kt @@ -0,0 +1,70 @@ +/* + * Copyright MURENA SAS 2023 + * Apps Quickly and easily install Android apps onto your device! + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package foundation.e.apps.login + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import com.aurora.gplayapi.data.models.AuthData +import foundation.e.apps.data.ResultSupreme +import foundation.e.apps.data.enums.User +import foundation.e.apps.data.login.AuthObject +import foundation.e.apps.data.login.LoginSourceRepository +import foundation.e.apps.data.login.LoginViewModel +import okhttp3.Cache +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.mockito.Mock +import org.mockito.MockitoAnnotations + +class LoginViewModelTest { + + @Mock + private lateinit var loginSourceRepository: LoginSourceRepository + @Mock + private lateinit var cache: Cache + + private lateinit var loginViewModel: LoginViewModel + + @Suppress("unused") + @get:Rule + val instantTaskExecutorRule: InstantTaskExecutorRule = InstantTaskExecutorRule() + + @Before + fun setup() { + MockitoAnnotations.openMocks(this) + loginViewModel = LoginViewModel(loginSourceRepository, cache) + } + + @Test + fun testMarkInvalidAuthObject() { + val authObjectList = mutableListOf( + AuthObject.GPlayAuth( + ResultSupreme.Success(AuthData("aa@aa.com", "feri4234")), User.GOOGLE + ) + ) + loginViewModel.authObjects.value = authObjectList + + loginViewModel.markInvalidAuthObject(AuthObject.GPlayAuth::class.java.simpleName) + val currentAuthObjectList = loginViewModel.authObjects.value as List + val invalidGplayAuth = currentAuthObjectList.find { it is AuthObject.GPlayAuth } + + assert(invalidGplayAuth != null) + assert((invalidGplayAuth as AuthObject.GPlayAuth).result.isUnknownError()) + } +} \ No newline at end of file diff --git a/app/src/test/java/foundation/e/apps/util/FakeCall.kt b/app/src/test/java/foundation/e/apps/util/FakeCall.kt new file mode 100644 index 0000000000000000000000000000000000000000..1c6aa71d81dc64eb600d897a6eb7078b9f9ebc81 --- /dev/null +++ b/app/src/test/java/foundation/e/apps/util/FakeCall.kt @@ -0,0 +1,79 @@ +/* + * Copyright MURENA SAS 2023 + * Apps Quickly and easily install Android apps onto your device! + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package foundation.e.apps.util + +import okhttp3.Call +import okhttp3.Callback +import okhttp3.Protocol +import okhttp3.Request +import okhttp3.Response +import okhttp3.ResponseBody +import okhttp3.ResponseBody.Companion.toResponseBody +import okio.Timeout + +class FakeCall : Call { + + var willThrow401 = false + + companion object { + const val FAKE_URL = "https://abc.abc" + } + + private val fakeRequest = Request.Builder().url(FAKE_URL).build() + override fun cancel() { + TODO("Not yet implemented") + } + + override fun clone(): Call { + TODO("Not yet implemented") + } + + override fun enqueue(responseCallback: Callback) { + TODO("Not yet implemented") + } + + override fun execute(): Response { + if (willThrow401) { + return Response.Builder() + .request(fakeRequest) + .protocol(Protocol.HTTP_2) + .message("") + .code(401) + .body("".toResponseBody()) + .build() + } + return Response.Builder().build() + } + + override fun isCanceled(): Boolean { + TODO("Not yet implemented") + } + + override fun isExecuted(): Boolean { + TODO("Not yet implemented") + } + + override fun request(): Request { + TODO("Not yet implemented") + } + + override fun timeout(): Timeout { + TODO("Not yet implemented") + } +} \ No newline at end of file