Loading app/build.gradle +3 −0 Original line number Diff line number Diff line Loading @@ -331,6 +331,9 @@ dependencies { // JSoup implementation(libs.jsoup) // Jake Wharton's DiskLruCache implementation(libs.disklrucache) } def retrieveKey(String keyName, String defaultValue) { Loading app/src/main/java/foundation/e/apps/data/playstore/utils/CacheUtil.kt 0 → 100644 +38 −0 Original line number Diff line number Diff line /* * Copyright (C) 2025 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 <https://www.gnu.org/licenses/>. * */ package foundation.e.apps.data.playstore.utils import okhttp3.RequestBody import okio.Buffer import java.nio.charset.StandardCharsets import java.security.MessageDigest /** Compute the MD5 hash of [input] as a lowercase hex string. */ fun md5(input: String): String { val bytes = MessageDigest.getInstance("MD5").digest(input.toByteArray(StandardCharsets.UTF_8)) return bytes.joinToString("") { "%02x".format(it) } } /** Return the UTF-8 string of [body], or empty string if null. */ fun bodyToString(body: RequestBody?): String { if (body == null) return "" val buffer = Buffer() body.writeTo(buffer) return buffer.readUtf8() } app/src/main/java/foundation/e/apps/data/playstore/utils/CachingInterceptor.kt 0 → 100644 +75 −0 Original line number Diff line number Diff line /* * Copyright (C) 2025 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 <https://www.gnu.org/licenses/>. * */ package foundation.e.apps.data.playstore.utils import com.jakewharton.disklrucache.DiskLruCache import okhttp3.Interceptor import okhttp3.Protocol import okhttp3.Response import okhttp3.ResponseBody.Companion.toResponseBody import java.io.File @Suppress("MagicNumber") // FIXME: move to const class CachingInterceptor(cacheDirectory: File) : Interceptor { private val diskCache: DiskLruCache = DiskLruCache.open( cacheDirectory, /* appVersion = */ 1, /* valueCount = */ 1, /* maxSize = */ 30L * 1024L * 1024L, // 30 MB ) @Suppress("ReturnCount") // FIXME: refactor override fun intercept(chain: Interceptor.Chain): Response { val request = chain.request() val key = md5(request.url.toString() + bodyToString(request.body)) // 1. Try cache diskCache.get(key)?.use { snapshot -> // You can read as a String directly: val cachedString = snapshot.getString(0) return Response.Builder() .request(request) .protocol(Protocol.HTTP_1_1) .code(200) // FIXME: move to const .message("OK (cache)") .body(cachedString.toResponseBody(request.body?.contentType())) .build() } // 2. Cache miss: proceed to network val networkResponse = chain.proceed(request) val responseBody = networkResponse.body ?: return networkResponse val bytes = responseBody.bytes() // 3. Write to cache if possible diskCache.edit(key)?.let { editor -> // Use an OutputStream to write raw bytes: editor.newOutputStream(0).use { os -> os.write(bytes) } editor.commit() } // 4. Return a new response with the consumed body return networkResponse .newBuilder() .body(bytes.toResponseBody(responseBody.contentType())) .build() } } app/src/main/java/foundation/e/apps/data/playstore/utils/GPlayHttpClient.kt +24 −3 Original line number Diff line number Diff line Loading @@ -42,21 +42,26 @@ import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.Response import okhttp3.logging.HttpLoggingInterceptor import timber.log.Timber import java.io.File import java.io.IOException import java.net.SocketTimeoutException import java.util.Locale import java.util.concurrent.TimeUnit import javax.inject.Inject @Suppress("MagicNumber") // FIXME: move to const class GPlayHttpClient @Inject constructor( private val cache: Cache, loggingInterceptor: HttpLoggingInterceptor loggingInterceptor: HttpLoggingInterceptor, cacheDirectory: File ) : IHttpClient { companion object { private const val HTTP_TIMEOUT_IN_SECOND = 10L private const val HTTP_METHOD_POST = "POST" private const val HTTP_METHOD_GET = "GET" private const val TAG_WEB_REQUEST = "TAG_WEB_REQUEST" private const val PLAY_STORE_WEB_URL = "https://play.google.com/_/PlayStoreUi/data/batchexecute" private const val SEARCH_SUGGEST = "searchSuggest" private const val STATUS_CODE_OK = 200 const val STATUS_CODE_UNAUTHORIZED = 401 Loading @@ -66,6 +71,11 @@ class GPlayHttpClient @Inject constructor( private const val INITIAL_RESPONSE_CODE = 100 } private val cache by lazy { // FIXME: move to const val cacheSize = (15 * 1024 * 1024).toLong() // 15 MB Cache(cacheDirectory, cacheSize) } private val _responseCode = MutableStateFlow(INITIAL_RESPONSE_CODE) override val responseCode: StateFlow<Int> Loading @@ -77,8 +87,15 @@ class GPlayHttpClient @Inject constructor( .callTimeout(HTTP_TIMEOUT_IN_SECOND, TimeUnit.SECONDS) .followRedirects(true) .followSslRedirects(true) .addInterceptor { chain -> return@addInterceptor if (chain.request().tag() == TAG_WEB_REQUEST) { CachingInterceptor(cacheDirectory).intercept(chain) } else { chain.proceed(chain.request()) } } .cache(cache) .addInterceptor(loggingInterceptor) .addNetworkInterceptor(loggingInterceptor) .build() @Throws(IOException::class) Loading @@ -87,6 +104,10 @@ class GPlayHttpClient @Inject constructor( .url(url) .headers(headersWithLocale(headers).toHeaders()) .method(HTTP_METHOD_POST, requestBody) .tag( if (url.contains(PLAY_STORE_WEB_URL, ignoreCase = true) ) TAG_WEB_REQUEST else "" ) .build() return processRequest(request) } Loading app/src/main/java/foundation/e/apps/di/CommonUtilsModule.kt +8 −0 Original line number Diff line number Diff line Loading @@ -38,6 +38,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import okhttp3.Cache import java.io.File import java.lang.reflect.Modifier import javax.inject.Named import javax.inject.Singleton Loading Loading @@ -115,6 +116,13 @@ object CommonUtilsModule { return Cache(context.cacheDir, cacheSize) } @Singleton @Provides fun provideGPlayClientCacheDirectory(@ApplicationContext context: Context): File { val file = File(context.cacheDir, "gplay_client_cache") return file } /** * Prevents calling a route if the navigation is already done, i.e. prevents duplicate calls. * Source: https://nezspencer.medium.com/navigation-components-a-fix-for-navigation-action-cannot-be-found-in-the-current-destination-95b63e16152e Loading Loading
app/build.gradle +3 −0 Original line number Diff line number Diff line Loading @@ -331,6 +331,9 @@ dependencies { // JSoup implementation(libs.jsoup) // Jake Wharton's DiskLruCache implementation(libs.disklrucache) } def retrieveKey(String keyName, String defaultValue) { Loading
app/src/main/java/foundation/e/apps/data/playstore/utils/CacheUtil.kt 0 → 100644 +38 −0 Original line number Diff line number Diff line /* * Copyright (C) 2025 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 <https://www.gnu.org/licenses/>. * */ package foundation.e.apps.data.playstore.utils import okhttp3.RequestBody import okio.Buffer import java.nio.charset.StandardCharsets import java.security.MessageDigest /** Compute the MD5 hash of [input] as a lowercase hex string. */ fun md5(input: String): String { val bytes = MessageDigest.getInstance("MD5").digest(input.toByteArray(StandardCharsets.UTF_8)) return bytes.joinToString("") { "%02x".format(it) } } /** Return the UTF-8 string of [body], or empty string if null. */ fun bodyToString(body: RequestBody?): String { if (body == null) return "" val buffer = Buffer() body.writeTo(buffer) return buffer.readUtf8() }
app/src/main/java/foundation/e/apps/data/playstore/utils/CachingInterceptor.kt 0 → 100644 +75 −0 Original line number Diff line number Diff line /* * Copyright (C) 2025 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 <https://www.gnu.org/licenses/>. * */ package foundation.e.apps.data.playstore.utils import com.jakewharton.disklrucache.DiskLruCache import okhttp3.Interceptor import okhttp3.Protocol import okhttp3.Response import okhttp3.ResponseBody.Companion.toResponseBody import java.io.File @Suppress("MagicNumber") // FIXME: move to const class CachingInterceptor(cacheDirectory: File) : Interceptor { private val diskCache: DiskLruCache = DiskLruCache.open( cacheDirectory, /* appVersion = */ 1, /* valueCount = */ 1, /* maxSize = */ 30L * 1024L * 1024L, // 30 MB ) @Suppress("ReturnCount") // FIXME: refactor override fun intercept(chain: Interceptor.Chain): Response { val request = chain.request() val key = md5(request.url.toString() + bodyToString(request.body)) // 1. Try cache diskCache.get(key)?.use { snapshot -> // You can read as a String directly: val cachedString = snapshot.getString(0) return Response.Builder() .request(request) .protocol(Protocol.HTTP_1_1) .code(200) // FIXME: move to const .message("OK (cache)") .body(cachedString.toResponseBody(request.body?.contentType())) .build() } // 2. Cache miss: proceed to network val networkResponse = chain.proceed(request) val responseBody = networkResponse.body ?: return networkResponse val bytes = responseBody.bytes() // 3. Write to cache if possible diskCache.edit(key)?.let { editor -> // Use an OutputStream to write raw bytes: editor.newOutputStream(0).use { os -> os.write(bytes) } editor.commit() } // 4. Return a new response with the consumed body return networkResponse .newBuilder() .body(bytes.toResponseBody(responseBody.contentType())) .build() } }
app/src/main/java/foundation/e/apps/data/playstore/utils/GPlayHttpClient.kt +24 −3 Original line number Diff line number Diff line Loading @@ -42,21 +42,26 @@ import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.Response import okhttp3.logging.HttpLoggingInterceptor import timber.log.Timber import java.io.File import java.io.IOException import java.net.SocketTimeoutException import java.util.Locale import java.util.concurrent.TimeUnit import javax.inject.Inject @Suppress("MagicNumber") // FIXME: move to const class GPlayHttpClient @Inject constructor( private val cache: Cache, loggingInterceptor: HttpLoggingInterceptor loggingInterceptor: HttpLoggingInterceptor, cacheDirectory: File ) : IHttpClient { companion object { private const val HTTP_TIMEOUT_IN_SECOND = 10L private const val HTTP_METHOD_POST = "POST" private const val HTTP_METHOD_GET = "GET" private const val TAG_WEB_REQUEST = "TAG_WEB_REQUEST" private const val PLAY_STORE_WEB_URL = "https://play.google.com/_/PlayStoreUi/data/batchexecute" private const val SEARCH_SUGGEST = "searchSuggest" private const val STATUS_CODE_OK = 200 const val STATUS_CODE_UNAUTHORIZED = 401 Loading @@ -66,6 +71,11 @@ class GPlayHttpClient @Inject constructor( private const val INITIAL_RESPONSE_CODE = 100 } private val cache by lazy { // FIXME: move to const val cacheSize = (15 * 1024 * 1024).toLong() // 15 MB Cache(cacheDirectory, cacheSize) } private val _responseCode = MutableStateFlow(INITIAL_RESPONSE_CODE) override val responseCode: StateFlow<Int> Loading @@ -77,8 +87,15 @@ class GPlayHttpClient @Inject constructor( .callTimeout(HTTP_TIMEOUT_IN_SECOND, TimeUnit.SECONDS) .followRedirects(true) .followSslRedirects(true) .addInterceptor { chain -> return@addInterceptor if (chain.request().tag() == TAG_WEB_REQUEST) { CachingInterceptor(cacheDirectory).intercept(chain) } else { chain.proceed(chain.request()) } } .cache(cache) .addInterceptor(loggingInterceptor) .addNetworkInterceptor(loggingInterceptor) .build() @Throws(IOException::class) Loading @@ -87,6 +104,10 @@ class GPlayHttpClient @Inject constructor( .url(url) .headers(headersWithLocale(headers).toHeaders()) .method(HTTP_METHOD_POST, requestBody) .tag( if (url.contains(PLAY_STORE_WEB_URL, ignoreCase = true) ) TAG_WEB_REQUEST else "" ) .build() return processRequest(request) } Loading
app/src/main/java/foundation/e/apps/di/CommonUtilsModule.kt +8 −0 Original line number Diff line number Diff line Loading @@ -38,6 +38,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import okhttp3.Cache import java.io.File import java.lang.reflect.Modifier import javax.inject.Named import javax.inject.Singleton Loading Loading @@ -115,6 +116,13 @@ object CommonUtilsModule { return Cache(context.cacheDir, cacheSize) } @Singleton @Provides fun provideGPlayClientCacheDirectory(@ApplicationContext context: Context): File { val file = File(context.cacheDir, "gplay_client_cache") return file } /** * Prevents calling a route if the navigation is already done, i.e. prevents duplicate calls. * Source: https://nezspencer.medium.com/navigation-components-a-fix-for-navigation-action-cannot-be-found-in-the-current-destination-95b63e16152e Loading