Skip to content

Commit

Permalink
ktor CacheStorage based persistent cache (#61)
Browse files Browse the repository at this point in the history
* ktor CacheStorage based persistent cache

* merge fix

* remove okio dependency from kamel-image

* remove buildconfig

---------

Co-authored-by: Luca Spinazzola <mspinluca@gmail.com>
  • Loading branch information
psuzn and luca992 authored Oct 15, 2023
1 parent 36c6da7 commit e8c2f5f
Show file tree
Hide file tree
Showing 25 changed files with 1,414 additions and 15 deletions.
5 changes: 5 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ com-android-application = { id = "com.android.application", version.ref = "agp"
kotlin = "1.9.10"
agp = "8.1.2"

okio = "3.5.0"
startup-runtime = "1.1.1"
vanniktech-publish = "0.25.3"
multiplatform-resources = "0.23.0"
compose = "1.5.3"
Expand All @@ -28,6 +30,9 @@ batik = "1.17"

[libraries]

androidx-startup = { module = "androidx.startup:startup-runtime", version.ref = "startup-runtime" }
okio = { module = "com.squareup.okio:okio", version.ref = "okio" }
okio-fakefilesystem = { module = "com.squareup.okio:okio-fakefilesystem", version.ref = "okio" }
org-jetbrains-kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin" }

kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }
Expand Down
46 changes: 43 additions & 3 deletions kamel-core/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,17 @@ plugins {
alias(libs.plugins.org.jetbrains.compose)
`maven-publish`
signing
alias(libs.plugins.com.android.library)
}

kotlin {

explicitApi = ExplicitApiMode.Warning

jvm()
androidTarget()

jvm("desktop")

js(IR) {
browser()
}
Expand Down Expand Up @@ -61,6 +65,7 @@ kotlin {
implementation(compose.runtime)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.ktor.client.core)
implementation(libs.okio)
}
}

Expand All @@ -70,17 +75,38 @@ kotlin {
implementation(kotlin("test"))
implementation(libs.ktor.client.mock)
implementation(libs.kotlinx.coroutines.test)
implementation(libs.okio.fakefilesystem)
}
}

val jvmMain by getting {
val commonJvmMain = create("commonJvmMain") {
dependsOn(commonMain)
dependencies {
implementation(libs.org.jetbrains.kotlin.reflect)
}
}

val jvmTest by getting {
val commonJvmTest = create("commonJvmTest") {
dependsOn(commonTest)
}

val desktopMain by getting {
dependsOn(commonJvmMain)
}

val desktopTest by getting {
dependsOn(commonJvmTest)
}

val androidMain by getting {
dependsOn(commonJvmMain)
dependencies {
implementation(libs.androidx.startup)
}
}

val androidUnitTest by getting {
dependsOn(commonJvmTest)
}

val nonJvmMain by creating {
Expand Down Expand Up @@ -174,4 +200,18 @@ tasks.withType<AbstractPublishToMaven>().configureEach {
dependsOn(dependsOnTasks)
}

android {
namespace = "io.kamel.core.cache"
compileSdk = 34

defaultConfig {
minSdk = 21
}

composeOptions {
kotlinCompilerExtensionVersion = "1.5.3"
}

}

apply(from = "$rootDir/gradle/pack-core-tests-resources.gradle.kts")
21 changes: 21 additions & 0 deletions kamel-core/src/androidMain/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">

<uses-permission android:name="android.permission.INTERNET" />

<application>

<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false"
tools:node="merge">

<meta-data
android:name="io.kamel.core.ApplicationContextInitializer"
android:value="androidx.startup" />
</provider>

</application>
</manifest>
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package io.kamel.core

import android.content.Context
import androidx.startup.Initializer


internal lateinit var applicationContext: Context

internal class ApplicationContextInitializer : Initializer<Context> {
override fun create(context: Context): Context = context.also {
applicationContext = it
}

override fun dependencies(): List<Class<out Initializer<*>>> = emptyList()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package io.kamel.core.cache

import io.kamel.core.applicationContext
import io.kamel.core.cache.disk.DiskCacheStorage
import io.ktor.client.plugins.cache.storage.CacheStorage
import okio.FileSystem
import okio.Path.Companion.toOkioPath

private val cacheDir = applicationContext.cacheDir.toOkioPath()

internal actual fun httpCacheStorage(maxSize: Long): CacheStorage = DiskCacheStorage(
fileSystem = FileSystem.SYSTEM,
directory = cacheDir,
maxSize = maxSize
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
package io.kamel.core.cache.disk


import io.kamel.core.utils.Kamel
import io.ktor.client.plugins.cache.storage.*
import io.ktor.http.*
import io.ktor.util.*
import io.ktor.util.date.*
import io.ktor.utils.io.core.use
import kotlinx.coroutines.*
import okio.BufferedSink
import okio.BufferedSource
import okio.ByteString.Companion.encodeUtf8
import okio.FileSystem
import okio.Path


private fun Url.hash() = toString().encodeUtf8().md5().hex()

private fun DiskLruCache.Editor.abortQuietly() {
try {
abort()
} catch (_: Exception) {
}
}

/**
* storage that uses file system to store cache data.
* @param fileSystem underlying filesystem.
* @param directory directory to store cache data.
* @param maxSize maximum cache size
* @param dispatcher dispatcher to use for file operations.
*/
internal class DiskCacheStorage(
private val fileSystem: FileSystem,
directory: Path,
maxSize: Long,
dispatcher: CoroutineDispatcher = Dispatchers.Kamel,
) : CacheStorage {

private val diskLruCache by lazy { DiskLruCache(fileSystem, directory, dispatcher, maxSize) }

override suspend fun store(url: Url, data: CachedResponseData) {
diskLruCache.edit(url.hash())?.let { editor ->
try {
fileSystem.write(editor.file()) {
writeCache(this, data)
}
editor.commit()
} catch (_: Exception) {
editor.abortQuietly()
}
}
}

override suspend fun find(url: Url, varyKeys: Map<String, String>): CachedResponseData? {
return diskLruCache.get(url.hash())?.use {
try {
fileSystem.read(it.file()) {
readCache(this)
}
} catch (_: Exception) {
null
}
}
}

override suspend fun findAll(url: Url): Set<CachedResponseData> {
return find(url, emptyMap())?.let(::setOf) ?: emptySet()
}

private fun readCache(source: BufferedSource): CachedResponseData {
val url = source.readUtf8Line()!!
val status = HttpStatusCode(source.readInt(), source.readUtf8Line()!!)
val version = HttpProtocolVersion.parse(source.readUtf8Line()!!)
val headersCount = source.readInt()
val headers = HeadersBuilder()
for (j in 0 until headersCount) {
val key = source.readUtf8Line()!!
val value = source.readUtf8Line()!!
headers.append(key, value)
}
val requestTime = GMTDate(source.readLong())
val responseTime = GMTDate(source.readLong())
val expirationTime = GMTDate(source.readLong())
val varyKeysCount = source.readInt()
val varyKeys = buildMap {
for (j in 0 until varyKeysCount) {
val key = source.readUtf8Line()!!
val value = source.readUtf8Line()!!
put(key, value)
}
}
val bodyCount = source.readInt()
val body = ByteArray(bodyCount)
source.readFully(body)
return CachedResponseData(
url = Url(url),
statusCode = status,
requestTime = requestTime,
responseTime = responseTime,
version = version,
expires = expirationTime,
headers = headers.build(),
varyKeys = varyKeys,
body = body
)
}

private fun writeCache(channel: BufferedSink, cache: CachedResponseData) {
channel.writeUtf8(cache.url.toString() + "\n")
channel.writeInt(cache.statusCode.value)
channel.writeUtf8(cache.statusCode.description + "\n")
channel.writeUtf8(cache.version.toString() + "\n")
val headers = cache.headers.flattenEntries()
channel.writeInt(headers.size)
for ((key, value) in headers) {
channel.writeUtf8(key + "\n")
channel.writeUtf8(value + "\n")
}
channel.writeLong(cache.requestTime.timestamp)
channel.writeLong(cache.responseTime.timestamp)
channel.writeLong(cache.expires.timestamp)
channel.writeInt(cache.varyKeys.size)
for ((key, value) in cache.varyKeys) {
channel.writeUtf8(key + "\n")
channel.writeUtf8(value + "\n")
}
channel.writeInt(cache.body.size)
channel.write(cache.body)
}
}
Loading

0 comments on commit e8c2f5f

Please sign in to comment.