diff --git a/app/build.gradle b/app/build.gradle
index 13614bddf24d154c92c66c2c972a8ad1e24ba9e6..fccd9ec47a910ae80697cca3f6f31e34a0ec170d 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -331,6 +331,9 @@ dependencies {
// JSoup
implementation(libs.jsoup)
+
+ // Jake Wharton's DiskLruCache
+ implementation(libs.disklrucache)
}
def retrieveKey(String keyName, String defaultValue) {
diff --git a/app/src/main/java/foundation/e/apps/data/playstore/utils/CacheUtil.kt b/app/src/main/java/foundation/e/apps/data/playstore/utils/CacheUtil.kt
new file mode 100644
index 0000000000000000000000000000000000000000..78455cbcdf2d67a60d89c2ab57b619aa00b1bd75
--- /dev/null
+++ b/app/src/main/java/foundation/e/apps/data/playstore/utils/CacheUtil.kt
@@ -0,0 +1,38 @@
+/*
+ * 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 .
+ *
+ */
+
+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()
+}
diff --git a/app/src/main/java/foundation/e/apps/data/playstore/utils/CachingInterceptor.kt b/app/src/main/java/foundation/e/apps/data/playstore/utils/CachingInterceptor.kt
new file mode 100644
index 0000000000000000000000000000000000000000..3ca4780c383d97d17399dc22fec5563f1a4ee1d3
--- /dev/null
+++ b/app/src/main/java/foundation/e/apps/data/playstore/utils/CachingInterceptor.kt
@@ -0,0 +1,76 @@
+/*
+ * 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 .
+ *
+ */
+
+package foundation.e.apps.data.playstore.utils
+
+import com.jakewharton.disklrucache.DiskLruCache
+import foundation.e.apps.BuildConfig
+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 = */ BuildConfig.VERSION_CODE,
+ /* 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()
+ }
+}
diff --git a/app/src/main/java/foundation/e/apps/data/playstore/utils/GPlayHttpClient.kt b/app/src/main/java/foundation/e/apps/data/playstore/utils/GPlayHttpClient.kt
index 9ea2d6a1dcb5117e8c98e64a0dc984ec960257fc..e0b53b3b941801f522552458073db01d5283dc83 100644
--- a/app/src/main/java/foundation/e/apps/data/playstore/utils/GPlayHttpClient.kt
+++ b/app/src/main/java/foundation/e/apps/data/playstore/utils/GPlayHttpClient.kt
@@ -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
@@ -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
@@ -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)
@@ -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)
}
diff --git a/app/src/main/java/foundation/e/apps/di/CommonUtilsModule.kt b/app/src/main/java/foundation/e/apps/di/CommonUtilsModule.kt
index 312ed053788c3aedbcf3b5d9e54990ec97be03c2..096bbcb5ca49bb82df90e22f1d779c0e3b298f1a 100644
--- a/app/src/main/java/foundation/e/apps/di/CommonUtilsModule.kt
+++ b/app/src/main/java/foundation/e/apps/di/CommonUtilsModule.kt
@@ -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
@@ -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
diff --git a/app/src/main/java/foundation/e/apps/di/network/NetworkModule.kt b/app/src/main/java/foundation/e/apps/di/network/NetworkModule.kt
index cdeb0e363459775920d9e9ad6dffd030804356fc..c5fe8aeb891ed60949d63679e9ab7d7768d8f092 100644
--- a/app/src/main/java/foundation/e/apps/di/network/NetworkModule.kt
+++ b/app/src/main/java/foundation/e/apps/di/network/NetworkModule.kt
@@ -83,7 +83,7 @@ object NetworkModule {
): OkHttpClient {
return OkHttpClient.Builder()
.addInterceptor(interceptor)
- .addInterceptor(httpLoggingInterceptor) // Put logging interceptor last
+ .addNetworkInterceptor(httpLoggingInterceptor) // Put logging interceptor last
.callTimeout(HTTP_TIMEOUT_IN_SECOND, TimeUnit.SECONDS)
.cache(cache)
.build()
diff --git a/app/src/test/java/foundation/e/apps/gplay/GplyHttpClientTest.kt b/app/src/test/java/foundation/e/apps/gplay/GPlayHttpClientTest.kt
similarity index 96%
rename from app/src/test/java/foundation/e/apps/gplay/GplyHttpClientTest.kt
rename to app/src/test/java/foundation/e/apps/gplay/GPlayHttpClientTest.kt
index 3ebcf2e29afbd724fc8fe298c247f0a56d122598..813698ab57ff1b6ec5dc093b63fc405a86fb9f90 100644
--- a/app/src/test/java/foundation/e/apps/gplay/GplyHttpClientTest.kt
+++ b/app/src/test/java/foundation/e/apps/gplay/GPlayHttpClientTest.kt
@@ -19,8 +19,8 @@
package foundation.e.apps.gplay
import com.aurora.gplayapi.data.models.PlayResponse
-import foundation.e.apps.data.playstore.utils.GPlayHttpClient
import foundation.e.apps.data.login.AuthObject
+import foundation.e.apps.data.playstore.utils.GPlayHttpClient
import foundation.e.apps.data.playstore.utils.GplayHttpRequestException
import foundation.e.apps.util.FakeCall
import foundation.e.apps.util.MainCoroutineRule
@@ -32,7 +32,6 @@ 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 okhttp3.logging.HttpLoggingInterceptor
@@ -45,13 +44,13 @@ import org.mockito.Mock
import org.mockito.Mockito
import org.mockito.MockitoAnnotations
import org.mockito.kotlin.any
+import java.io.File
import kotlin.test.assertFailsWith
@OptIn(ExperimentalCoroutinesApi::class)
-class GplyHttpClientTest {
+class GPlayHttpClientTest {
- @Mock
- private lateinit var cache: Cache
+ private lateinit var cacheDirectory: File
@Mock
private lateinit var loggingInterceptor: HttpLoggingInterceptor
@@ -70,7 +69,8 @@ class GplyHttpClientTest {
@Before
fun setup() {
MockitoAnnotations.openMocks(this)
- gPlayHttpClient = GPlayHttpClient(cache, loggingInterceptor)
+ cacheDirectory = File("gplay_client_cache_test")
+ gPlayHttpClient = GPlayHttpClient(loggingInterceptor, cacheDirectory)
gPlayHttpClient.okHttpClient = this.okHttpClient
call = FakeCall()
}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 02ca4c5acd255065c325b938288445ac3a8ce33e..8adcecf356e9f507951b0425e657c8aaef3d61f0 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -49,6 +49,7 @@ truth = "1.1.4"
viewpager2 = "1.1.0"
recyclerview = "1.4.0"
workRuntimeKtx = "2.10.0"
+disklrucache = "2.0.2"
[libraries]
activity-ktx = { module = "androidx.activity:activity-ktx", version.ref = "activityKtx" }
@@ -109,6 +110,7 @@ timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" }
truth = { module = "com.google.truth:truth", version.ref = "truth" }
viewpager2 = { module = "androidx.viewpager2:viewpager2", version.ref = "viewpager2" }
work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "workRuntimeKtx" }
+disklrucache = { module = "com.jakewharton:disklrucache", version.ref = "disklrucache"}
[plugins]
android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" }