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" }