diff --git a/FloconAndroid/.vscode/settings.json b/FloconAndroid/.vscode/settings.json new file mode 100644 index 00000000..c5f3f6b9 --- /dev/null +++ b/FloconAndroid/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "java.configuration.updateBuildConfiguration": "interactive" +} \ No newline at end of file diff --git a/FloconAndroid/gradle/libs.versions.toml b/FloconAndroid/gradle/libs.versions.toml index 6c0c3b5a..d68aa031 100644 --- a/FloconAndroid/gradle/libs.versions.toml +++ b/FloconAndroid/gradle/libs.versions.toml @@ -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" } diff --git a/FloconAndroid/ktor-interceptor/build.gradle.kts b/FloconAndroid/ktor-interceptor/build.gradle.kts index 4434e9f1..0c849b57 100644 --- a/FloconAndroid/ktor-interceptor/build.gradle.kts +++ b/FloconAndroid/ktor-interceptor/build.gradle.kts @@ -30,11 +30,13 @@ kotlin { val androidMain by getting { dependencies { + implementation(libs.brotli.dec) } } val jvmMain by getting { dependencies { + implementation(libs.brotli.dec) } } diff --git a/FloconAndroid/ktor-interceptor/src/androidMain/kotlin/io/github/openflocon/flocon/ktor/DecodeUtils.kt b/FloconAndroid/ktor-interceptor/src/androidMain/kotlin/io/github/openflocon/flocon/ktor/DecodeUtils.kt new file mode 100644 index 00000000..66b8980a --- /dev/null +++ b/FloconAndroid/ktor-interceptor/src/androidMain/kotlin/io/github/openflocon/flocon/ktor/DecodeUtils.kt @@ -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 { + return if (headers.isBrotli()) { + BrotliInputStream(bytes.inputStream()).use { it.readBytes().toString(Charsets.UTF_8) } + } else { + bytes.decodeToString() + } +} diff --git a/FloconAndroid/ktor-interceptor/src/commonMain/kotlin/io/github/openflocon/flocon/ktor/FloconKtorPlugin.kt b/FloconAndroid/ktor-interceptor/src/commonMain/kotlin/io/github/openflocon/flocon/ktor/FloconKtorPlugin.kt index b10ae53c..5a947fea 100644 --- a/FloconAndroid/ktor-interceptor/src/commonMain/kotlin/io/github/openflocon/flocon/ktor/FloconKtorPlugin.kt +++ b/FloconAndroid/ktor-interceptor/src/commonMain/kotlin/io/github/openflocon/flocon/ktor/FloconKtorPlugin.kt @@ -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, diff --git a/FloconAndroid/ktor-interceptor/src/commonMain/kotlin/io/github/openflocon/flocon/ktor/Utils.kt b/FloconAndroid/ktor-interceptor/src/commonMain/kotlin/io/github/openflocon/flocon/ktor/Utils.kt index 8494b17d..ee821d2c 100644 --- a/FloconAndroid/ktor-interceptor/src/commonMain/kotlin/io/github/openflocon/flocon/ktor/Utils.kt +++ b/FloconAndroid/ktor-interceptor/src/commonMain/kotlin/io/github/openflocon/flocon/ktor/Utils.kt @@ -56,4 +56,21 @@ internal suspend fun extractAndReplaceRequestBody(request: HttpRequestBuilder): is OutgoingContent.NoContent -> null else -> originalBody?.toString() } -} \ No newline at end of file +} + +private fun Map.getContentEncoding() : String? { + return get("Content-Encoding") ?: get("content-encoding") +} + +private fun Map.isGzipped(): Boolean { + return "gzip".equals(getContentEncoding(), ignoreCase = true) +} + +internal fun Map.isBrotli(): Boolean { + return "br".equals(getContentEncoding(), ignoreCase = true) +} + +internal expect fun decodeNetworkBody( + bytes: ByteArray, + headers: Map +): String \ No newline at end of file diff --git a/FloconAndroid/ktor-interceptor/src/iosMain/kotlin/io/github/openflocon/flocon/ktor/DecodeUtils.kt b/FloconAndroid/ktor-interceptor/src/iosMain/kotlin/io/github/openflocon/flocon/ktor/DecodeUtils.kt new file mode 100644 index 00000000..d5368ec9 --- /dev/null +++ b/FloconAndroid/ktor-interceptor/src/iosMain/kotlin/io/github/openflocon/flocon/ktor/DecodeUtils.kt @@ -0,0 +1,6 @@ +package io.github.openflocon.flocon.ktor + +internal actual fun decodeNetworkBody(bytes: ByteArray, headers: Map): String { + // iOS does not have Brotli library; fallback to UTF-8 string + return bytes.decodeToString() +} diff --git a/FloconAndroid/ktor-interceptor/src/jvmMain/kotlin/io/github/openflocon/flocon/ktor/DecodeUtils.kt b/FloconAndroid/ktor-interceptor/src/jvmMain/kotlin/io/github/openflocon/flocon/ktor/DecodeUtils.kt new file mode 100644 index 00000000..b3fea33f --- /dev/null +++ b/FloconAndroid/ktor-interceptor/src/jvmMain/kotlin/io/github/openflocon/flocon/ktor/DecodeUtils.kt @@ -0,0 +1,11 @@ +package io.github.openflocon.flocon.ktor + +import org.brotli.dec.BrotliInputStream + +internal actual fun decodeNetworkBody(bytes: ByteArray, headers: Map): String { + return if (headers.isBrotli()) { + BrotliInputStream(bytes.inputStream()).use { it.readBytes().toString(Charsets.UTF_8) } + } else { + bytes.decodeToString() + } +} diff --git a/FloconAndroid/okhttp-interceptor/build.gradle.kts b/FloconAndroid/okhttp-interceptor/build.gradle.kts index ca0d474e..a7657f35 100644 --- a/FloconAndroid/okhttp-interceptor/build.gradle.kts +++ b/FloconAndroid/okhttp-interceptor/build.gradle.kts @@ -44,6 +44,9 @@ dependencies { implementation(platform(libs.okhttp.bom)) implementation(libs.okhttp3.okhttp) + implementation(libs.brotli.dec) + + testImplementation(libs.junit) } diff --git a/FloconAndroid/okhttp-interceptor/src/main/kotlin/io/github/openflocon/flocon/okhttp/Utils.kt b/FloconAndroid/okhttp-interceptor/src/main/kotlin/io/github/openflocon/flocon/okhttp/Utils.kt index 91d8bb28..2ebeaa8d 100644 --- a/FloconAndroid/okhttp-interceptor/src/main/kotlin/io/github/openflocon/flocon/okhttp/Utils.kt +++ b/FloconAndroid/okhttp-interceptor/src/main/kotlin/io/github/openflocon/flocon/okhttp/Utils.kt @@ -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.getContentEncoding() : String? { + return get("Content-Encoding") ?: get("content-encoding") +} + private fun Map.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.isBrotli(): Boolean { + val contentEncoding = getContentEncoding() ?: return false + return "br".equals(contentEncoding, ignoreCase = true) } \ No newline at end of file diff --git a/FloconAndroid/okhttp-interceptor/src/test/kotlin/io/github/openflocon/flocon/okhttp/UtilsTest.kt b/FloconAndroid/okhttp-interceptor/src/test/kotlin/io/github/openflocon/flocon/okhttp/UtilsTest.kt new file mode 100644 index 00000000..913e37b0 --- /dev/null +++ b/FloconAndroid/okhttp-interceptor/src/test/kotlin/io/github/openflocon/flocon/okhttp/UtilsTest.kt @@ -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) + } +}