fix: [COMPRESSION] brotli (#457)
Some checks are pending
docs / deploy (push) Waiting to run

Co-authored-by: Florent Champigny <florent@bere.al>
This commit is contained in:
Florent CHAMPIGNY 2025-12-23 10:30:31 +01:00 committed by GitHub
parent d4163217d5
commit e431bc75b3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 145 additions and 6 deletions

3
FloconAndroid/.vscode/settings.json vendored Normal file
View file

@ -0,0 +1,3 @@
{
"java.configuration.updateBuildConfiguration": "interactive"
}

View file

@ -31,6 +31,7 @@ processPhoenix = "3.0.0"
sqlite = "2.5.2"
sqliteJdbc = "3.50.3.0"
buildconfig = "5.6.8"
brotli = "0.1.2"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@ -38,6 +39,7 @@ androidx-datastore-preferences = { module = "androidx.datastore:datastore-prefer
androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" }
androidx-sqlite-bundled = { module = "androidx.sqlite:sqlite-bundled", version.ref = "sqlite" }
androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" }
brotli-dec = { module = "org.brotli:dec", version.ref = "brotli" }
apollo-http-okhttprealization = { module = "com.apollographql.apollo:apollo-http-okhttprealization", version.ref = "apollo" }
apollo-runtime = { module = "com.apollographql.apollo:apollo-runtime", version.ref = "apollo" }
coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coilCompose" }

View file

@ -30,11 +30,13 @@ kotlin {
val androidMain by getting {
dependencies {
implementation(libs.brotli.dec)
}
}
val jvmMain by getting {
dependencies {
implementation(libs.brotli.dec)
}
}

View file

@ -0,0 +1,12 @@
package io.github.openflocon.flocon.ktor
import org.brotli.dec.BrotliInputStream
import java.io.InputStream
internal actual fun decodeNetworkBody(bytes: ByteArray, headers: Map<String, String>): String {
return if (headers.isBrotli()) {
BrotliInputStream(bytes.inputStream()).use { it.readBytes().toString(Charsets.UTF_8) }
} else {
bytes.decodeToString()
}
}

View file

@ -181,7 +181,13 @@ val FloconKtorPlugin = createClientPlugin("FloconKtorPlugin", ::FloconKtorPlugin
val floconCallResponse = FloconNetworkResponse(
httpCode = response.status.value,
contentType = contentType,
body = if(isImage) null else originalBodyBytes.decodeToString(),
body = if (isImage) null else {
if (responseHeadersMap.isBrotli()) {
decodeNetworkBody(originalBodyBytes, responseHeadersMap)
} else {
originalBodyBytes.decodeToString()
}
},
headers = responseHeadersMap,
size = responseSize,
grpcStatus = null,

View file

@ -56,4 +56,21 @@ internal suspend fun extractAndReplaceRequestBody(request: HttpRequestBuilder):
is OutgoingContent.NoContent -> null
else -> originalBody?.toString()
}
}
}
private fun Map<String, String>.getContentEncoding() : String? {
return get("Content-Encoding") ?: get("content-encoding")
}
private fun Map<String, String>.isGzipped(): Boolean {
return "gzip".equals(getContentEncoding(), ignoreCase = true)
}
internal fun Map<String, String>.isBrotli(): Boolean {
return "br".equals(getContentEncoding(), ignoreCase = true)
}
internal expect fun decodeNetworkBody(
bytes: ByteArray,
headers: Map<String, String>
): String

View file

@ -0,0 +1,6 @@
package io.github.openflocon.flocon.ktor
internal actual fun decodeNetworkBody(bytes: ByteArray, headers: Map<String, String>): String {
// iOS does not have Brotli library; fallback to UTF-8 string
return bytes.decodeToString()
}

View file

@ -0,0 +1,11 @@
package io.github.openflocon.flocon.ktor
import org.brotli.dec.BrotliInputStream
internal actual fun decodeNetworkBody(bytes: ByteArray, headers: Map<String, String>): String {
return if (headers.isBrotli()) {
BrotliInputStream(bytes.inputStream()).use { it.readBytes().toString(Charsets.UTF_8) }
} else {
bytes.decodeToString()
}
}

View file

@ -44,6 +44,9 @@ dependencies {
implementation(platform(libs.okhttp.bom))
implementation(libs.okhttp3.okhttp)
implementation(libs.brotli.dec)
testImplementation(libs.junit)
}

View file

@ -5,6 +5,9 @@ import okhttp3.Request
import okhttp3.Response
import okio.Buffer
import okio.GzipSource
import okio.buffer
import okio.source
import org.brotli.dec.BrotliInputStream
import java.nio.charset.Charset
internal fun getHttpMessage(httpCode: Int): String {
@ -73,6 +76,7 @@ internal fun extractResponseBodyInfo(
}
val charset = responseBody.contentType().charsetOrUtf8()
bodySize = buffer.size
if (responseHeaders.isGzipped()) {
GzipSource(buffer.clone()).use { gzippedResponseBody ->
buffer = Buffer()
@ -80,10 +84,15 @@ internal fun extractResponseBodyInfo(
}
bodyString = buffer.clone().readString(charset)
bodySize = buffer.size
} else if (responseHeaders.isBrotli()) {
BrotliInputStream(buffer.clone().inputStream()).source().buffer().use { brotliResponseBody ->
buffer = Buffer()
buffer.writeAll(brotliResponseBody)
}
bodyString = buffer.clone().readString(charset)
} else {
bodyString = buffer.clone().readString(charset)
bodySize = buffer.size
}
return bodyString to bodySize
@ -100,12 +109,17 @@ internal fun extractRequestBodyInfo(
var buffer = Buffer()
requestBody.writeTo(buffer)
bodySize = buffer.size
if (requestHeaders.isGzipped()) {
bodySize = buffer.size
GzipSource(buffer).use { gzippedResponseBody ->
buffer = Buffer()
buffer.writeAll(gzippedResponseBody)
}
} else if (requestHeaders.isBrotli()) {
BrotliInputStream(buffer.inputStream()).source().buffer().use { brotliResponseBody ->
buffer = Buffer()
buffer.writeAll(brotliResponseBody)
}
}
val charset = requestBody.contentType().charsetOrUtf8()
@ -114,7 +128,16 @@ internal fun extractRequestBodyInfo(
return bodyString to bodySize
}
private fun Map<String, String>.getContentEncoding() : String? {
return get("Content-Encoding") ?: get("content-encoding")
}
private fun Map<String, String>.isGzipped(): Boolean {
val contentEncoding = get("Content-Encoding") ?: get("content-encoding") ?: return false
val contentEncoding = getContentEncoding() ?: return false
return "gzip".equals(contentEncoding, ignoreCase = true)
}
private fun Map<String, String>.isBrotli(): Boolean {
val contentEncoding = getContentEncoding() ?: return false
return "br".equals(contentEncoding, ignoreCase = true)
}

View file

@ -0,0 +1,54 @@
package io.github.openflocon.flocon.okhttp
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Protocol
import okhttp3.Request
import okhttp3.Response
import okhttp3.ResponseBody.Companion.toResponseBody
import okio.Buffer
import okio.ByteString.Companion.decodeHex
import org.junit.Assert.assertEquals
import org.junit.Test
class UtilsTest {
@Test
fun `extractResponseBodyInfo decodes brotli stream`() {
// "Hello, Brotli!" compressed with Brotli
val brotliHex = "8b068048656c6c6f2c2042726f746c692103"
val brotliBytes = brotliHex.decodeHex().toByteArray()
val responseBody = brotliBytes.toResponseBody("application/json".toMediaType())
val response = Response.Builder()
.request(Request.Builder().url("https://example.com").build())
.protocol(Protocol.HTTP_1_1)
.code(200)
.message("OK")
.header("Content-Encoding", "br")
.body(responseBody)
.build()
val (bodyString, bodySize) = extractResponseBodyInfo(response, response.headers.toMap())
assertEquals("Hello, Brotli!", bodyString)
}
@Test
fun `extractResponseBodyInfo handles plain text`() {
val plainText = "Hello, World!"
val responseBody = plainText.toResponseBody("text/plain".toMediaType())
val response = Response.Builder()
.request(Request.Builder().url("https://example.com").build())
.protocol(Protocol.HTTP_1_1)
.code(200)
.message("OK")
.body(responseBody)
.build()
val (bodyString, _) = extractResponseBodyInfo(response, response.headers.toMap())
assertEquals("Hello, World!", bodyString)
}
}