Loading app/src/main/java/foundation/e/apps/data/playstore/PlayStoreRepository.kt +47 −5 Original line number Diff line number Diff line Loading @@ -50,15 +50,23 @@ import foundation.e.apps.data.handleNetworkResult import foundation.e.apps.data.playstore.utils.GPlayHttpClient import foundation.e.apps.data.playstore.utils.GplayHttpRequestException import foundation.e.apps.data.preference.AppLoungeDataStore import foundation.e.apps.di.qualifiers.IoCoroutineScope import foundation.e.apps.utils.SystemInfoProvider import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.flow.timeout import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json import timber.log.Timber import java.util.Properties import javax.inject.Inject import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds import com.aurora.gplayapi.data.models.App as GplayApp @Suppress("TooManyFunctions", "LongParameterList") Loading @@ -72,7 +80,12 @@ class PlayStoreRepository @Inject constructor( private val googleLoginDataSource: GoogleLoginDataSource, private val nativeDeviceProperty: Properties, private val json: Json, @IoCoroutineScope private val ioCoroutineScope: CoroutineScope ) : StoreRepository { companion object { private val gPlayHttpClientPukedExceptionWaitDuration: Duration = 10.milliseconds } suspend fun updateToken(): AuthData { val userType = appLoungeDataStore.getUser() Loading Loading @@ -211,7 +224,9 @@ class PlayStoreRepository @Inject constructor( // batch request response might not contain images and descriptions of the app suspend fun getAppsDetails(packageNames: List<String>): List<Application> = withContext(Dispatchers.IO) { var appDetails: List<GplayApp> = getAppDetailsHelper().getAppByPackageName(packageNames) var appDetails: List<GplayApp> = doAuthenticatedRequest { authData -> AppDetailsHelper(authData).using(gPlayHttpClient).getAppByPackageName(packageNames) } if (!isEmulator() && appDetails.all { it.versionCode == 0L } && isAnonymousUser()) { // Google Play returns limited result ( i.e. version code being 0) with a stale token, Loading @@ -220,7 +235,10 @@ class PlayStoreRepository @Inject constructor( refreshPlayStoreAuthentication() appDetails = getAppDetailsHelper().getAppByPackageName(packageNames) appDetails = doAuthenticatedRequest { authData -> AppDetailsHelper(authData).using(gPlayHttpClient) .getAppByPackageName(packageNames) } if (appDetails.all { it.versionCode == 0L }) { Timber.w("After refreshing auth, version code is still 0.") Loading @@ -233,7 +251,9 @@ class PlayStoreRepository @Inject constructor( override suspend fun getAppDetails(packageName: String): Application = withContext(Dispatchers.IO) { var appDetails: GplayApp = getAppDetailsHelper().getAppByPackageName(packageName) var appDetails = doAuthenticatedRequest { authData -> AppDetailsHelper(authData).using(gPlayHttpClient).getAppByPackageName(packageName) } if (!isEmulator() && appDetails.versionCode == 0L && isAnonymousUser()) { // Google Play returns limited result ( i.e. version code being 0) with a stale token, Loading @@ -242,7 +262,9 @@ class PlayStoreRepository @Inject constructor( refreshPlayStoreAuthentication() appDetails = getAppDetailsHelper().getAppByPackageName(packageName) appDetails = doAuthenticatedRequest { authData -> AppDetailsHelper(authData).using(gPlayHttpClient).getAppByPackageName(packageName) } if (appDetails.versionCode == 0L) { Timber.w("After refreshing auth, version code is still 0. Giving up installation.") Loading Loading @@ -384,8 +406,28 @@ class PlayStoreRepository @Inject constructor( } private suspend fun <T> doAuthenticatedRequest(request: suspend (AuthData) -> T): T { val lastErrorFlow = gPlayHttpClient.requestException.shareIn( scope = ioCoroutineScope, started = SharingStarted.Eagerly, replay = 1 ) val authData = appLoungeDataStore.getAuthData() return request(authData) return try { request(authData) } catch(e: Exception) { lastErrorFlow.timeout(gPlayHttpClientPukedExceptionWaitDuration) .catch { Timber.d("No error emitted from GPlayHttpClient after timeout.") throw e }.collect { pukedException -> Timber.d(pukedException, "Error in GPlay request, Reswallow it for now.") // Re-swallow the exception, until upper layer ar eready o receive new exceptions. throw e } Timber.w("Un-swallow exception failed, reach un-reachable code") throw e } } // Helper function, to detect error not specific to the request Loading app/src/main/java/foundation/e/apps/data/playstore/utils/GPlayHttpClient.kt +76 −32 Original line number Diff line number Diff line Loading @@ -22,10 +22,18 @@ import androidx.annotation.VisibleForTesting import com.aurora.gplayapi.data.models.PlayResponse import com.aurora.gplayapi.network.IHttpClient import foundation.e.apps.data.login.AuthObject import foundation.e.apps.di.qualifiers.IoCoroutineScope import foundation.e.apps.domain.entities.ConnectivityException import foundation.e.apps.domain.entities.HttpFailedCodeException import foundation.e.apps.domain.entities.HttpTooManyRequestException import foundation.e.apps.domain.entities.HttpUnauthorizedException import foundation.e.apps.utils.SystemInfoProvider import foundation.e.apps.utils.eventBus.AppEvent import foundation.e.apps.utils.eventBus.EventBus import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.MainScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow Loading @@ -42,13 +50,17 @@ import okhttp3.Response import okhttp3.logging.HttpLoggingInterceptor import timber.log.Timber import java.io.IOException import java.net.ConnectException import java.net.NoRouteToHostException import java.net.SocketTimeoutException import java.net.UnknownHostException import java.util.Locale import java.util.concurrent.TimeUnit import javax.inject.Inject class GPlayHttpClient @Inject constructor( loggingInterceptor: HttpLoggingInterceptor loggingInterceptor: HttpLoggingInterceptor, @IoCoroutineScope private val ioCoroutineScope: CoroutineScope ) : IHttpClient { companion object { Loading @@ -69,6 +81,9 @@ class GPlayHttpClient @Inject constructor( override val responseCode: StateFlow<Int> get() = _responseCode.asStateFlow() private val _requestException = MutableSharedFlow<Exception>() val requestException: Flow<Exception> = _requestException @VisibleForTesting var okHttpClient = OkHttpClient().newBuilder() .retryOnConnectionFailure(false) Loading Loading @@ -176,12 +191,30 @@ class GPlayHttpClient @Inject constructor( return try { val call = okHttpClient.newCall(request) response = call.execute() buildPlayResponse(response) } catch (e: GplayHttpRequestException) { throw e buildPlayResponse(response).apply { _responseCode.value = code } } catch (e: Exception) { val status = if (e is SocketTimeoutException) STATUS_CODE_TIMEOUT else -1 throw GplayHttpRequestException(status, e.localizedMessage ?: "") val parsedException = when(e) { is SocketTimeoutException, is ConnectException, is UnknownHostException, is NoRouteToHostException -> ConnectivityException(cause = e) else -> e } ioCoroutineScope.launch { rawErrorResponse.emit(parsedException) } // Swallow the exception, until upper layer are upgraded. throw when(parsedException) { is HttpFailedCodeException -> GplayHttpRequestException(parsedException.statusCode, parsedException.message?: "HttpFailed exception ${parsedException.statusCode}") is ConnectivityException -> GplayHttpRequestException(if (e is SocketTimeoutException) STATUS_CODE_TIMEOUT else -1, parsedException.message?: "") else -> GplayHttpRequestException(-1, e.localizedMessage ?: "") } } finally { response?.close() } Loading @@ -199,9 +232,17 @@ class GPlayHttpClient @Inject constructor( val url = response.request.url val code = response.code val isSuccessful = response.isSuccessful val errorMessage = if (!isSuccessful) response.message else null val responseBytes = response.body?.bytes() ?: byteArrayOf() if (!isSuccessful) { val httpException = when(code) { STATUS_CODE_UNAUTHORIZED -> HttpUnauthorizedException(message = response.message) STATUS_CODE_TOO_MANY_REQUESTS -> HttpTooManyRequestException(message = response.message) else -> HttpFailedCodeException(statusCode = response.code, message = response.message) } // TODO: remove that when (code) { STATUS_CODE_UNAUTHORIZED -> MainScope().launch { EventBus.invokeEvent( Loading @@ -220,25 +261,28 @@ class GPlayHttpClient @Inject constructor( } } if (!url.toString().contains(URL_SUBSTRING_PURCHASE) && code !in listOf( // TODO 20251211: limit this hack to purchase procedure. if (url.toString().contains(URL_SUBSTRING_PURCHASE) && code in listOf( STATUS_CODE_OK, STATUS_CODE_UNAUTHORIZED ) ) { throw GplayHttpRequestException(code, response.message) return PlayResponse( isSuccessful = false, code = code, responseBytes = responseBytes, errorString = response.message ) } fun PlayResponse.withErrorString(error: String?): PlayResponse { return if (error == null) return this else copy(errorString = error) throw httpException } return PlayResponse( isSuccessful = isSuccessful, code = code, responseBytes = responseBytes, ).withErrorString(errorMessage).apply { _responseCode.value = response.code } responseBytes = responseBytes ) } } Loading app/src/main/java/foundation/e/apps/domain/entities/HttpExceptions.kt 0 → 100644 +9 −0 Original line number Diff line number Diff line package foundation.e.apps.domain.entities open class HttpFailedCodeException(val statusCode: Int = 520, message: String? = null, cause: Throwable? = null ): Exception(message?: "", cause) class HttpUnauthorizedException(message: String? = null, cause: Throwable? = null, ): HttpFailedCodeException(statusCode = 401, message = message, cause = cause) class HttpTooManyRequestException(message: String? = null, cause: Throwable? = null): HttpFailedCodeException(statusCode = 429, message = message, cause = cause) class ConnectivityException(message: String? = null, cause: Throwable) : Exception(message?: cause.message, cause) Loading
app/src/main/java/foundation/e/apps/data/playstore/PlayStoreRepository.kt +47 −5 Original line number Diff line number Diff line Loading @@ -50,15 +50,23 @@ import foundation.e.apps.data.handleNetworkResult import foundation.e.apps.data.playstore.utils.GPlayHttpClient import foundation.e.apps.data.playstore.utils.GplayHttpRequestException import foundation.e.apps.data.preference.AppLoungeDataStore import foundation.e.apps.di.qualifiers.IoCoroutineScope import foundation.e.apps.utils.SystemInfoProvider import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.flow.timeout import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json import timber.log.Timber import java.util.Properties import javax.inject.Inject import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds import com.aurora.gplayapi.data.models.App as GplayApp @Suppress("TooManyFunctions", "LongParameterList") Loading @@ -72,7 +80,12 @@ class PlayStoreRepository @Inject constructor( private val googleLoginDataSource: GoogleLoginDataSource, private val nativeDeviceProperty: Properties, private val json: Json, @IoCoroutineScope private val ioCoroutineScope: CoroutineScope ) : StoreRepository { companion object { private val gPlayHttpClientPukedExceptionWaitDuration: Duration = 10.milliseconds } suspend fun updateToken(): AuthData { val userType = appLoungeDataStore.getUser() Loading Loading @@ -211,7 +224,9 @@ class PlayStoreRepository @Inject constructor( // batch request response might not contain images and descriptions of the app suspend fun getAppsDetails(packageNames: List<String>): List<Application> = withContext(Dispatchers.IO) { var appDetails: List<GplayApp> = getAppDetailsHelper().getAppByPackageName(packageNames) var appDetails: List<GplayApp> = doAuthenticatedRequest { authData -> AppDetailsHelper(authData).using(gPlayHttpClient).getAppByPackageName(packageNames) } if (!isEmulator() && appDetails.all { it.versionCode == 0L } && isAnonymousUser()) { // Google Play returns limited result ( i.e. version code being 0) with a stale token, Loading @@ -220,7 +235,10 @@ class PlayStoreRepository @Inject constructor( refreshPlayStoreAuthentication() appDetails = getAppDetailsHelper().getAppByPackageName(packageNames) appDetails = doAuthenticatedRequest { authData -> AppDetailsHelper(authData).using(gPlayHttpClient) .getAppByPackageName(packageNames) } if (appDetails.all { it.versionCode == 0L }) { Timber.w("After refreshing auth, version code is still 0.") Loading @@ -233,7 +251,9 @@ class PlayStoreRepository @Inject constructor( override suspend fun getAppDetails(packageName: String): Application = withContext(Dispatchers.IO) { var appDetails: GplayApp = getAppDetailsHelper().getAppByPackageName(packageName) var appDetails = doAuthenticatedRequest { authData -> AppDetailsHelper(authData).using(gPlayHttpClient).getAppByPackageName(packageName) } if (!isEmulator() && appDetails.versionCode == 0L && isAnonymousUser()) { // Google Play returns limited result ( i.e. version code being 0) with a stale token, Loading @@ -242,7 +262,9 @@ class PlayStoreRepository @Inject constructor( refreshPlayStoreAuthentication() appDetails = getAppDetailsHelper().getAppByPackageName(packageName) appDetails = doAuthenticatedRequest { authData -> AppDetailsHelper(authData).using(gPlayHttpClient).getAppByPackageName(packageName) } if (appDetails.versionCode == 0L) { Timber.w("After refreshing auth, version code is still 0. Giving up installation.") Loading Loading @@ -384,8 +406,28 @@ class PlayStoreRepository @Inject constructor( } private suspend fun <T> doAuthenticatedRequest(request: suspend (AuthData) -> T): T { val lastErrorFlow = gPlayHttpClient.requestException.shareIn( scope = ioCoroutineScope, started = SharingStarted.Eagerly, replay = 1 ) val authData = appLoungeDataStore.getAuthData() return request(authData) return try { request(authData) } catch(e: Exception) { lastErrorFlow.timeout(gPlayHttpClientPukedExceptionWaitDuration) .catch { Timber.d("No error emitted from GPlayHttpClient after timeout.") throw e }.collect { pukedException -> Timber.d(pukedException, "Error in GPlay request, Reswallow it for now.") // Re-swallow the exception, until upper layer ar eready o receive new exceptions. throw e } Timber.w("Un-swallow exception failed, reach un-reachable code") throw e } } // Helper function, to detect error not specific to the request Loading
app/src/main/java/foundation/e/apps/data/playstore/utils/GPlayHttpClient.kt +76 −32 Original line number Diff line number Diff line Loading @@ -22,10 +22,18 @@ import androidx.annotation.VisibleForTesting import com.aurora.gplayapi.data.models.PlayResponse import com.aurora.gplayapi.network.IHttpClient import foundation.e.apps.data.login.AuthObject import foundation.e.apps.di.qualifiers.IoCoroutineScope import foundation.e.apps.domain.entities.ConnectivityException import foundation.e.apps.domain.entities.HttpFailedCodeException import foundation.e.apps.domain.entities.HttpTooManyRequestException import foundation.e.apps.domain.entities.HttpUnauthorizedException import foundation.e.apps.utils.SystemInfoProvider import foundation.e.apps.utils.eventBus.AppEvent import foundation.e.apps.utils.eventBus.EventBus import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.MainScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow Loading @@ -42,13 +50,17 @@ import okhttp3.Response import okhttp3.logging.HttpLoggingInterceptor import timber.log.Timber import java.io.IOException import java.net.ConnectException import java.net.NoRouteToHostException import java.net.SocketTimeoutException import java.net.UnknownHostException import java.util.Locale import java.util.concurrent.TimeUnit import javax.inject.Inject class GPlayHttpClient @Inject constructor( loggingInterceptor: HttpLoggingInterceptor loggingInterceptor: HttpLoggingInterceptor, @IoCoroutineScope private val ioCoroutineScope: CoroutineScope ) : IHttpClient { companion object { Loading @@ -69,6 +81,9 @@ class GPlayHttpClient @Inject constructor( override val responseCode: StateFlow<Int> get() = _responseCode.asStateFlow() private val _requestException = MutableSharedFlow<Exception>() val requestException: Flow<Exception> = _requestException @VisibleForTesting var okHttpClient = OkHttpClient().newBuilder() .retryOnConnectionFailure(false) Loading Loading @@ -176,12 +191,30 @@ class GPlayHttpClient @Inject constructor( return try { val call = okHttpClient.newCall(request) response = call.execute() buildPlayResponse(response) } catch (e: GplayHttpRequestException) { throw e buildPlayResponse(response).apply { _responseCode.value = code } } catch (e: Exception) { val status = if (e is SocketTimeoutException) STATUS_CODE_TIMEOUT else -1 throw GplayHttpRequestException(status, e.localizedMessage ?: "") val parsedException = when(e) { is SocketTimeoutException, is ConnectException, is UnknownHostException, is NoRouteToHostException -> ConnectivityException(cause = e) else -> e } ioCoroutineScope.launch { rawErrorResponse.emit(parsedException) } // Swallow the exception, until upper layer are upgraded. throw when(parsedException) { is HttpFailedCodeException -> GplayHttpRequestException(parsedException.statusCode, parsedException.message?: "HttpFailed exception ${parsedException.statusCode}") is ConnectivityException -> GplayHttpRequestException(if (e is SocketTimeoutException) STATUS_CODE_TIMEOUT else -1, parsedException.message?: "") else -> GplayHttpRequestException(-1, e.localizedMessage ?: "") } } finally { response?.close() } Loading @@ -199,9 +232,17 @@ class GPlayHttpClient @Inject constructor( val url = response.request.url val code = response.code val isSuccessful = response.isSuccessful val errorMessage = if (!isSuccessful) response.message else null val responseBytes = response.body?.bytes() ?: byteArrayOf() if (!isSuccessful) { val httpException = when(code) { STATUS_CODE_UNAUTHORIZED -> HttpUnauthorizedException(message = response.message) STATUS_CODE_TOO_MANY_REQUESTS -> HttpTooManyRequestException(message = response.message) else -> HttpFailedCodeException(statusCode = response.code, message = response.message) } // TODO: remove that when (code) { STATUS_CODE_UNAUTHORIZED -> MainScope().launch { EventBus.invokeEvent( Loading @@ -220,25 +261,28 @@ class GPlayHttpClient @Inject constructor( } } if (!url.toString().contains(URL_SUBSTRING_PURCHASE) && code !in listOf( // TODO 20251211: limit this hack to purchase procedure. if (url.toString().contains(URL_SUBSTRING_PURCHASE) && code in listOf( STATUS_CODE_OK, STATUS_CODE_UNAUTHORIZED ) ) { throw GplayHttpRequestException(code, response.message) return PlayResponse( isSuccessful = false, code = code, responseBytes = responseBytes, errorString = response.message ) } fun PlayResponse.withErrorString(error: String?): PlayResponse { return if (error == null) return this else copy(errorString = error) throw httpException } return PlayResponse( isSuccessful = isSuccessful, code = code, responseBytes = responseBytes, ).withErrorString(errorMessage).apply { _responseCode.value = response.code } responseBytes = responseBytes ) } } Loading
app/src/main/java/foundation/e/apps/domain/entities/HttpExceptions.kt 0 → 100644 +9 −0 Original line number Diff line number Diff line package foundation.e.apps.domain.entities open class HttpFailedCodeException(val statusCode: Int = 520, message: String? = null, cause: Throwable? = null ): Exception(message?: "", cause) class HttpUnauthorizedException(message: String? = null, cause: Throwable? = null, ): HttpFailedCodeException(statusCode = 401, message = message, cause = cause) class HttpTooManyRequestException(message: String? = null, cause: Throwable? = null): HttpFailedCodeException(statusCode = 429, message = message, cause = cause) class ConnectivityException(message: String? = null, cause: Throwable) : Exception(message?: cause.message, cause)