From c0d636ebca34521d6cf3014f6f55fee155c87ed9 Mon Sep 17 00:00:00 2001 From: "M. Reza Nasirloo" Date: Thu, 21 May 2026 15:37:19 +0200 Subject: [PATCH 1/3] feat: add ChuckerGrpcInterceptor for gRPC traffic inspection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds ChuckerGrpcInterceptor to library and library-no-op so gRPC calls are visible alongside HTTP traffic in the Chucker UI. Usage: val channel = OkHttpChannelBuilder.forAddress(host, port) .intercept(ChuckerGrpcInterceptor(collector, context)) .build() Swap library-no-op's no-op variant for release builds — constructor signatures match exactly for source-level compatibility. Supported call types: unary, server-streaming, client-streaming, bidirectional streaming. Streaming responses update live as messages arrive rather than waiting for the stream to close. Features: - Request/response headers (with optional redaction) - Body capture up to maxContentLength (default 250 KB), truncated if exceeded - gRPC status code and description - Response trailers shown alongside response headers - Duration tracking grpc-api is a compileOnly dependency; users who integrate gRPC already have it on their classpath, and users who don't are unaffected. Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 1 + gradle/libs.versions.toml | 15 ++ library-no-op/api/library-no-op.api | 6 + library-no-op/build.gradle.kts | 1 + .../chucker/api/ChuckerGrpcInterceptor.kt | 34 +++ library/api/library.api | 6 + library/build.gradle.kts | 1 + .../chucker/api/ChuckerGrpcInterceptor.kt | 207 ++++++++++++++++++ sample/build.gradle.kts | 37 ++++ sample/src/main/AndroidManifest.xml | 1 + .../chuckerteam/chucker/sample/GrpcTask.kt | 137 ++++++++++++ .../chucker/sample/MainActivity.kt | 10 + .../chucker/sample/SampleApplication.kt | 37 ++++ .../chucker/sample/SampleGrpcServer.kt | 105 +++++++++ .../sample/compose/ChuckerSampleControls.kt | 10 + .../sample/compose/ChuckerSampleMainScreen.kt | 10 + .../compose/testtags/ChuckerTestTags.kt | 1 + sample/src/main/proto/sample_service.proto | 21 ++ sample/src/main/res/values/strings.xml | 1 + 19 files changed, 641 insertions(+) create mode 100644 library-no-op/src/main/kotlin/com/chuckerteam/chucker/api/ChuckerGrpcInterceptor.kt create mode 100644 library/src/main/kotlin/com/chuckerteam/chucker/api/ChuckerGrpcInterceptor.kt create mode 100644 sample/src/main/kotlin/com/chuckerteam/chucker/sample/GrpcTask.kt create mode 100644 sample/src/main/kotlin/com/chuckerteam/chucker/sample/SampleApplication.kt create mode 100644 sample/src/main/kotlin/com/chuckerteam/chucker/sample/SampleGrpcServer.kt create mode 100644 sample/src/main/proto/sample_service.proto diff --git a/CHANGELOG.md b/CHANGELOG.md index d11550246..308d82b8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ Please add your entries according to this format. ### Added - Long-press the payload copy button to choose between copying the raw body or the formatted body [#1613] +- `ChuckerGrpcInterceptor` — a gRPC `ClientInterceptor` that captures all four gRPC call types (unary, server-streaming, client-streaming, bidirectional) and displays them alongside HTTP traffic in Chucker. Streaming responses update live as messages arrive. Add it to your channel via `OkHttpChannelBuilder.intercept(ChuckerGrpcInterceptor(collector, context))`. Swap `library-no-op`'s no-op variant for release builds. ### Fixed diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 09ec6f73a..cae50e154 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -60,6 +60,11 @@ androidxEspresso = "3.7.0" nexusPublishPlugin = "2.0.0" uiTestJunit4 = "1.11.1" +# gRPC +grpc = "1.73.0" +protobuf = "4.31.1" +protobufGradlePlugin = "0.9.4" + [libraries] # Google Libraries androidx-activity-ktx = { group = "androidx.activity", name = "activity-ktx", version.ref = "activity" } @@ -127,6 +132,15 @@ truth = { group = "com.google.truth", name = "truth", version.ref = "truth" } kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4", version.ref = "uiTestJunit4" } +# gRPC +grpc-api = { group = "io.grpc", name = "grpc-api", version.ref = "grpc" } +grpc-stub = { group = "io.grpc", name = "grpc-stub", version.ref = "grpc" } +grpc-okhttp = { group = "io.grpc", name = "grpc-okhttp", version.ref = "grpc" } +grpc-netty-shaded = { group = "io.grpc", name = "grpc-netty-shaded", version.ref = "grpc" } +protobuf-java = { group = "com.google.protobuf", name = "protobuf-java", version.ref = "protobuf" } +protoc = { group = "com.google.protobuf", name = "protoc", version.ref = "protobuf" } +protoc-gen-grpc-java = { group = "io.grpc", name = "protoc-gen-grpc-java", version.ref = "grpc" } + [plugins] android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" } android-library = { id = "com.android.library", version.ref = "androidGradlePlugin" } @@ -140,4 +154,5 @@ kotlinx-binary-compatibility-validator = { id = "org.jetbrains.kotlinx.binary-co wire = { id = "com.squareup.wire", version.ref = "wire" } nexus-publish = { id = "io.github.gradle-nexus.publish-plugin", version.ref = "nexusPublishPlugin" } compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +protobuf = { id = "com.google.protobuf", version.ref = "protobufGradlePlugin" } diff --git a/library-no-op/api/library-no-op.api b/library-no-op/api/library-no-op.api index 68fdd699f..b17eb97bb 100644 --- a/library-no-op/api/library-no-op.api +++ b/library-no-op/api/library-no-op.api @@ -21,6 +21,12 @@ public final class com/chuckerteam/chucker/api/ChuckerCollector { public static synthetic fun writeTransactions$default (Lcom/chuckerteam/chucker/api/ChuckerCollector;Landroid/content/Context;Ljava/lang/Long;Lcom/chuckerteam/chucker/api/ExportFormat;ILjava/lang/Object;)Landroid/net/Uri; } +public final class com/chuckerteam/chucker/api/ChuckerGrpcInterceptor : io/grpc/ClientInterceptor { + public fun (Lcom/chuckerteam/chucker/api/ChuckerCollector;Landroid/content/Context;JLjava/util/Set;)V + public synthetic fun (Lcom/chuckerteam/chucker/api/ChuckerCollector;Landroid/content/Context;JLjava/util/Set;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun interceptCall (Lio/grpc/MethodDescriptor;Lio/grpc/CallOptions;Lio/grpc/Channel;)Lio/grpc/ClientCall; +} + public final class com/chuckerteam/chucker/api/ChuckerInterceptor : okhttp3/Interceptor { public fun (Landroid/content/Context;)V public synthetic fun (Lcom/chuckerteam/chucker/api/ChuckerInterceptor$Builder;Lkotlin/jvm/internal/DefaultConstructorMarker;)V diff --git a/library-no-op/build.gradle.kts b/library-no-op/build.gradle.kts index 90f73bffb..d5c5b26f9 100644 --- a/library-no-op/build.gradle.kts +++ b/library-no-op/build.gradle.kts @@ -52,6 +52,7 @@ tasks.withType().configureEach { dependencies { api(libs.okhttp) + compileOnly(libs.grpc.api) implementation(libs.jetbrains.kotlin.stdlib) } diff --git a/library-no-op/src/main/kotlin/com/chuckerteam/chucker/api/ChuckerGrpcInterceptor.kt b/library-no-op/src/main/kotlin/com/chuckerteam/chucker/api/ChuckerGrpcInterceptor.kt new file mode 100644 index 000000000..97d6bd97b --- /dev/null +++ b/library-no-op/src/main/kotlin/com/chuckerteam/chucker/api/ChuckerGrpcInterceptor.kt @@ -0,0 +1,34 @@ +package com.chuckerteam.chucker.api + +import android.content.Context +import io.grpc.CallOptions +import io.grpc.Channel +import io.grpc.ClientCall +import io.grpc.ClientInterceptor +import io.grpc.MethodDescriptor + +/** + * No-op implementation of [ChuckerGrpcInterceptor] for use in release builds. + * + * All parameters are accepted for source-level API compatibility with the debug variant + * but have no effect at runtime; calls pass straight through to the channel. + */ +public class ChuckerGrpcInterceptor private constructor( + @Suppress("UNUSED_PARAMETER") private val collector: ChuckerCollector, + @Suppress("UNUSED_PARAMETER") private val maxContentLength: Long, + @Suppress("UNUSED_PARAMETER") private val headersToRedact: Set, +) : ClientInterceptor { + + public constructor( + collector: ChuckerCollector, + @Suppress("UNUSED_PARAMETER") context: Context, + maxContentLength: Long = 250_000L, + redactHeaders: Set = emptySet(), + ) : this(collector, maxContentLength, redactHeaders) + + override fun interceptCall( + method: MethodDescriptor, + callOptions: CallOptions, + next: Channel, + ): ClientCall = next.newCall(method, callOptions) +} diff --git a/library/api/library.api b/library/api/library.api index 330dbeb19..1dcdf3ec1 100644 --- a/library/api/library.api +++ b/library/api/library.api @@ -21,6 +21,12 @@ public final class com/chuckerteam/chucker/api/ChuckerCollector { public static synthetic fun writeTransactions$default (Lcom/chuckerteam/chucker/api/ChuckerCollector;Landroid/content/Context;Ljava/lang/Long;Lcom/chuckerteam/chucker/api/ExportFormat;ILjava/lang/Object;)Landroid/net/Uri; } +public final class com/chuckerteam/chucker/api/ChuckerGrpcInterceptor : io/grpc/ClientInterceptor { + public fun (Lcom/chuckerteam/chucker/api/ChuckerCollector;Landroid/content/Context;JLjava/util/Set;)V + public synthetic fun (Lcom/chuckerteam/chucker/api/ChuckerCollector;Landroid/content/Context;JLjava/util/Set;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun interceptCall (Lio/grpc/MethodDescriptor;Lio/grpc/CallOptions;Lio/grpc/Channel;)Lio/grpc/ClientCall; +} + public final class com/chuckerteam/chucker/api/ChuckerInterceptor : okhttp3/Interceptor { public fun (Landroid/content/Context;)V public synthetic fun (Lcom/chuckerteam/chucker/api/ChuckerInterceptor$Builder;Lkotlin/jvm/internal/DefaultConstructorMarker;)V diff --git a/library/build.gradle.kts b/library/build.gradle.kts index 72c08662c..4d7e1e773 100644 --- a/library/build.gradle.kts +++ b/library/build.gradle.kts @@ -97,6 +97,7 @@ dependencies { api(libs.okhttp) api(libs.okhttp3.okhttp) + compileOnly(libs.grpc.api) testImplementation(libs.mockwebserver) testRuntimeOnly(libs.junit.engine) testRuntimeOnly(libs.junit.platform.launcher) diff --git a/library/src/main/kotlin/com/chuckerteam/chucker/api/ChuckerGrpcInterceptor.kt b/library/src/main/kotlin/com/chuckerteam/chucker/api/ChuckerGrpcInterceptor.kt new file mode 100644 index 000000000..c4fc1d542 --- /dev/null +++ b/library/src/main/kotlin/com/chuckerteam/chucker/api/ChuckerGrpcInterceptor.kt @@ -0,0 +1,207 @@ +package com.chuckerteam.chucker.api + +import android.content.Context +import com.chuckerteam.chucker.internal.data.entity.HttpHeader +import com.chuckerteam.chucker.internal.data.entity.HttpTransaction +import com.chuckerteam.chucker.internal.support.JsonConverter +import com.google.gson.reflect.TypeToken +import io.grpc.CallOptions +import io.grpc.Channel +import io.grpc.ClientCall +import io.grpc.ClientInterceptor +import io.grpc.ForwardingClientCall +import io.grpc.ForwardingClientCallListener +import io.grpc.Metadata +import io.grpc.MethodDescriptor +import io.grpc.Status +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicLong + +/** + * A gRPC [ClientInterceptor] that records requests and responses for inspection in Chucker. + * + * Add it to your gRPC channel: + * ```kotlin + * val channel = OkHttpChannelBuilder.forAddress(host, port) + * .intercept(ChuckerGrpcInterceptor(collector, context)) + * .build() + * ``` + * + * In release builds swap to [com.chuckerteam.chucker.api.ChuckerGrpcInterceptor] from the + * `library-no-op` artifact to eliminate all overhead. + * + * Streaming calls update live: the transaction appears in Chucker as soon as the server + * sends its initial headers, and the response body grows as messages arrive. + * + * @param collector [ChuckerCollector] that stores and displays the intercepted transactions. + * @param context Android context (unused at runtime, kept for API parity with the no-op variant). + * @param maxContentLength Maximum body size in bytes to record. Bodies larger than this are + * truncated. Defaults to 250 000 bytes. + * @param redactHeaders Header names whose values should be replaced with `**REDACTED**`. + */ +public class ChuckerGrpcInterceptor private constructor( + private val collector: ChuckerCollector, + private val maxContentLength: Long, + private val headersToRedact: Set, +) : ClientInterceptor { + + public constructor( + collector: ChuckerCollector, + @Suppress("UNUSED_PARAMETER") context: Context, + maxContentLength: Long = 250_000L, + redactHeaders: Set = emptySet(), + ) : this(collector, maxContentLength, redactHeaders.map { it.lowercase() }.toSet()) + + override fun interceptCall( + method: MethodDescriptor, + callOptions: CallOptions, + next: Channel, + ): ClientCall { + val transaction = HttpTransaction() + transaction.requestDate = System.currentTimeMillis() + transaction.method = method.type.name + transaction.protocol = "gRPC" + + val authority = next.authority() ?: "unknown" + transaction.host = authority.substringBefore(":") + transaction.path = "/${method.fullMethodName}" + transaction.scheme = if (authority.substringAfterLast(':').toIntOrNull() == 443) "https" else "http" + transaction.url = "${transaction.scheme}://$authority${transaction.path}" + + // Guards to ensure onRequestSent is called exactly once before any onResponseReceived. + val transactionStarted = AtomicBoolean(false) + + val requestBodyLock = Any() + val requestBody = StringBuilder() + val requestBodySize = AtomicLong(0L) + + val responseBodyLock = Any() + val responseBody = StringBuilder() + val responseBodySize = AtomicLong(0L) + + fun ensureStarted() { + if (transactionStarted.compareAndSet(false, true)) { + collector.onRequestSent(transaction) + } + } + + fun updateRequestBody() { + val body = synchronized(requestBodyLock) { requestBody.toString() } + val size = requestBodySize.get() + transaction.requestBody = body.truncatedTo(size, maxContentLength) + transaction.requestPayloadSize = size + } + + return object : ForwardingClientCall.SimpleForwardingClientCall( + next.newCall(method, callOptions), + ) { + override fun start(responseListener: Listener, headers: Metadata) { + transaction.setRequestHeaders(headers.toHttpHeaders(headersToRedact)) + transaction.requestContentType = "application/grpc" + + super.start( + object : ForwardingClientCallListener.SimpleForwardingClientCallListener( + responseListener, + ) { + override fun onHeaders(responseHeaders: Metadata) { + transaction.responseDate = System.currentTimeMillis() + transaction.setResponseHeaders(responseHeaders.toHttpHeaders(headersToRedact)) + transaction.responseContentType = "application/grpc" + // Insert the transaction so it appears in Chucker immediately. + ensureStarted() + super.onHeaders(responseHeaders) + } + + override fun onMessage(message: RespT) { + val text = message.toString() + val size = responseBodySize.addAndGet(text.length.toLong()) + if (size <= maxContentLength) { + synchronized(responseBodyLock) { responseBody.append(text) } + } + // Live update: refresh the response body after each message. + val body = synchronized(responseBodyLock) { responseBody.toString() } + transaction.responseBody = body.truncatedTo(responseBodySize.get(), maxContentLength) + transaction.responsePayloadSize = responseBodySize.get() + collector.onResponseReceived(transaction) + super.onMessage(message) + } + + override fun onClose(status: Status, trailers: Metadata) { + transaction.responseCode = status.code.value() + transaction.responseMessage = buildString { + append(status.code.name) + status.description?.let { append(" ($it)") } + } + status.cause?.let { transaction.error = it.toString() } + transaction.appendResponseTrailers(trailers.toHttpHeaders(headersToRedact)) + transaction.tookMs = + System.currentTimeMillis() - (transaction.requestDate ?: System.currentTimeMillis()) + + // Ensure the transaction was started even on immediate errors + // (where onHeaders was never called). + ensureStarted() + collector.onResponseReceived(transaction) + super.onClose(status, trailers) + } + }, + headers, + ) + } + + override fun sendMessage(message: ReqT) { + val text = message.toString() + val size = requestBodySize.addAndGet(text.length.toLong()) + if (size <= maxContentLength) { + synchronized(requestBodyLock) { requestBody.append(text) } + } + // Keep the visible request body current for bidi-streaming calls + // where responses (and thus onRequestSent) may have already fired. + if (transactionStarted.get()) { + updateRequestBody() + collector.onResponseReceived(transaction) + } + super.sendMessage(message) + } + + override fun halfClose() { + // Client finished sending — capture the final request body. + updateRequestBody() + // If server headers haven't arrived yet (e.g. slow server), ensure + // the transaction is in the DB so the request side is visible. + ensureStarted() + collector.onResponseReceived(transaction) + super.halfClose() + } + } + } + + private fun Metadata.toHttpHeaders(redact: Set): List = + keys().flatMap { key -> + val isBinary = key.endsWith(Metadata.BINARY_HEADER_SUFFIX) + val values: Iterable = + if (isBinary) { + getAll(Metadata.Key.of(key, Metadata.BINARY_BYTE_MARSHALLER)) + ?.map { it.toString(Charsets.UTF_8) + " (binary)" } + ?: emptyList() + } else { + getAll(Metadata.Key.of(key, Metadata.ASCII_STRING_MARSHALLER)) ?: emptyList() + } + val displayKey = key.lowercase() + values.map { HttpHeader(key, if (redact.contains(displayKey)) "**REDACTED**" else it) } + } + + private fun HttpTransaction.appendResponseTrailers(trailers: List) { + if (trailers.isEmpty()) return + val type = object : TypeToken>() {}.type + val existing = + JsonConverter.instance.fromJson>(responseHeaders ?: "[]", type) + ?: emptyList() + responseHeaders = + JsonConverter.instance.toJson( + existing + trailers.map { HttpHeader("(Trailer) ${it.name}", it.value) }, + ) + } + + private fun String.truncatedTo(actualSize: Long, max: Long): String = + if (actualSize > max) take(max.toInt()) + "\n\n--- (truncated)" else this +} diff --git a/sample/build.gradle.kts b/sample/build.gradle.kts index 2433b4baf..a3d41369d 100644 --- a/sample/build.gradle.kts +++ b/sample/build.gradle.kts @@ -1,9 +1,12 @@ +import com.google.protobuf.gradle.id + plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.android) alias(libs.plugins.wire) alias(libs.plugins.apollo) alias(libs.plugins.compose.compiler) + alias(libs.plugins.protobuf) } wire { @@ -65,6 +68,34 @@ android { disable.addAll(listOf("AcceptsUserCertificates", "GradleDependency")) warningsAsErrors = true } + + sourceSets { + named("main") { + java { + srcDir("build/generated/source/proto/main/grpc") + srcDir("build/generated/source/proto/main/java") + } + } + } +} + +protobuf { + protoc { + artifact = libs.protoc.get().toString() + } + plugins { + id("grpc") { + artifact = libs.protoc.gen.grpc.java.get().toString() + } + } + generateProtoTasks { + all().forEach { + it.plugins { + id("java") + id("grpc") + } + } + } } apollo { @@ -110,6 +141,12 @@ dependencies { implementation(libs.androidx.material3.window.size) debugImplementation(libs.leakcanary.android) + + // gRPC for demo purposes + implementation(libs.grpc.okhttp) + implementation(libs.grpc.stub) + implementation(libs.grpc.netty.shaded) + implementation(libs.protobuf.java) } apply(from = rootProject.file("gradle/kotlin-static-analysis.gradle")) diff --git a/sample/src/main/AndroidManifest.xml b/sample/src/main/AndroidManifest.xml index 72fa3515a..5aa57f979 100644 --- a/sample/src/main/AndroidManifest.xml +++ b/sample/src/main/AndroidManifest.xml @@ -5,6 +5,7 @@ { + override fun onNext(value: com.chuckerteam.chucker.sample.grpc.HelloReply) { + Log.i(TAG, "ClientStream: ${value.message}") + } + + override fun onError(t: Throwable) { + Log.e(TAG, "ClientStream error", t) + latch.countDown() + } + + override fun onCompleted() { + latch.countDown() + } + }) + try { + listOf("User1", "User2", "User3").forEach { + observer.onNext(HelloRequest.newBuilder().setName(it).build()) + delay(200) + } + observer.onCompleted() + latch.await(10, TimeUnit.SECONDS) + } catch (e: Exception) { + Log.e(TAG, "ClientStream failed", e) + } + } + + private suspend fun runBidiStream(stub: SampleGreeterGrpc.SampleGreeterStub) { + val latch = CountDownLatch(1) + val observer = stub.sayHelloBidiStream(object : StreamObserver { + override fun onNext(value: com.chuckerteam.chucker.sample.grpc.HelloReply) { + Log.i(TAG, "BidiStream: ${value.message}") + } + + override fun onError(t: Throwable) { + Log.e(TAG, "BidiStream error", t) + latch.countDown() + } + + override fun onCompleted() { + latch.countDown() + } + }) + try { + listOf("BidiUser1", "BidiUser2", "BidiUser3").asFlow().map { + delay(300) + HelloRequest.newBuilder().setName(it).build() + }.collect { observer.onNext(it) } + observer.onCompleted() + latch.await(10, TimeUnit.SECONDS) + } catch (e: Exception) { + Log.e(TAG, "BidiStream failed", e) + } + } + + private companion object { + const val TAG = "GrpcTask" + } +} diff --git a/sample/src/main/kotlin/com/chuckerteam/chucker/sample/MainActivity.kt b/sample/src/main/kotlin/com/chuckerteam/chucker/sample/MainActivity.kt index 1c1fb605e..bd9e42013 100644 --- a/sample/src/main/kotlin/com/chuckerteam/chucker/sample/MainActivity.kt +++ b/sample/src/main/kotlin/com/chuckerteam/chucker/sample/MainActivity.kt @@ -42,6 +42,8 @@ class MainActivity : ComponentActivity() { ) } + private val grpcTask by lazy { GrpcTask(this) } + @OptIn(ExperimentalMaterial3WindowSizeClassApi::class) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -63,6 +65,9 @@ class MainActivity : ComponentActivity() { task.run() } }, + onDoGrpc = { + grpcTask.execute() + }, onDoGraphQL = { GraphQlTask(client).run() }, @@ -99,6 +104,11 @@ class MainActivity : ComponentActivity() { ) } + override fun onDestroy() { + super.onDestroy() + grpcTask.cancel() + } + private fun launchChuckerDirectly() { // Optionally launch Chucker directly from your own app UI startActivity(Chucker.getLaunchIntent(this)) diff --git a/sample/src/main/kotlin/com/chuckerteam/chucker/sample/SampleApplication.kt b/sample/src/main/kotlin/com/chuckerteam/chucker/sample/SampleApplication.kt new file mode 100644 index 000000000..c22a2a3cb --- /dev/null +++ b/sample/src/main/kotlin/com/chuckerteam/chucker/sample/SampleApplication.kt @@ -0,0 +1,37 @@ +package com.chuckerteam.chucker.sample + +import android.app.Application +import com.chuckerteam.chucker.api.ChuckerCollector +import com.chuckerteam.chucker.api.RetentionManager +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch + +class SampleApplication : Application() { + + private val serverScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private val grpcServer by lazy { SampleGrpcServer(GRPC_PORT) } + + val chuckerCollector by lazy { + ChuckerCollector( + context = this, + showNotification = true, + retentionPeriod = RetentionManager.Period.ONE_HOUR, + ) + } + + override fun onCreate() { + super.onCreate() + serverScope.launch { grpcServer.start() } + } + + override fun onTerminate() { + super.onTerminate() + serverScope.launch { grpcServer.stop() } + } + + companion object { + const val GRPC_PORT = 50051 + } +} diff --git a/sample/src/main/kotlin/com/chuckerteam/chucker/sample/SampleGrpcServer.kt b/sample/src/main/kotlin/com/chuckerteam/chucker/sample/SampleGrpcServer.kt new file mode 100644 index 000000000..976c6d080 --- /dev/null +++ b/sample/src/main/kotlin/com/chuckerteam/chucker/sample/SampleGrpcServer.kt @@ -0,0 +1,105 @@ +package com.chuckerteam.chucker.sample + +import android.util.Log +import com.chuckerteam.chucker.sample.grpc.HelloReply +import com.chuckerteam.chucker.sample.grpc.HelloRequest +import com.chuckerteam.chucker.sample.grpc.SampleGreeterGrpc +import io.grpc.Server +import io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder +import io.grpc.stub.StreamObserver +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.asExecutor +import java.io.IOException +import java.util.concurrent.TimeUnit + +internal class SampleGrpcServer(private val port: Int) { + + private val server: Server by lazy { + NettyServerBuilder.forPort(port) + .addService(GreeterService()) + .executor(Dispatchers.IO.asExecutor()) + .build() + } + + fun start() { + try { + server.start() + Log.i(TAG, "Server started on port $port") + } catch (e: IOException) { + Log.e(TAG, "Failed to start server", e) + } + } + + fun stop() { + try { + server.shutdown().awaitTermination(5, TimeUnit.SECONDS) + Log.i(TAG, "Server stopped") + } catch (e: InterruptedException) { + Log.w(TAG, "Shutdown interrupted, forcing stop", e) + server.shutdownNow() + Thread.currentThread().interrupt() + } + } + + private class GreeterService : SampleGreeterGrpc.SampleGreeterImplBase() { + + override fun sayHello(req: HelloRequest, responseObserver: StreamObserver) { + responseObserver.onNext(HelloReply.newBuilder().setMessage("Hello ${req.name} (Unary)").build()) + responseObserver.onCompleted() + } + + override fun sayHelloServerStream(req: HelloRequest, responseObserver: StreamObserver) { + repeat(3) { i -> + responseObserver.onNext( + HelloReply.newBuilder().setMessage("Hello ${req.name}, part ${i + 1} (Server Stream)").build(), + ) + Thread.sleep(300) + } + responseObserver.onCompleted() + } + + override fun sayHelloClientStream( + responseObserver: StreamObserver, + ): StreamObserver { + val names = StringBuilder() + return object : StreamObserver { + override fun onNext(value: HelloRequest) { + if (names.isNotEmpty()) names.append(", ") + names.append(value.name) + } + + override fun onError(t: Throwable) { + responseObserver.onError(io.grpc.Status.fromThrowable(t).asRuntimeException()) + } + + override fun onCompleted() { + responseObserver.onNext(HelloReply.newBuilder().setMessage("Hello $names! (Client Stream)").build()) + responseObserver.onCompleted() + } + } + } + + override fun sayHelloBidiStream( + responseObserver: StreamObserver, + ): StreamObserver = + object : StreamObserver { + override fun onNext(value: HelloRequest) { + responseObserver.onNext( + HelloReply.newBuilder().setMessage("Ack: ${value.name} (Bidi Stream)").build(), + ) + } + + override fun onError(t: Throwable) { + responseObserver.onError(io.grpc.Status.fromThrowable(t).asRuntimeException()) + } + + override fun onCompleted() { + responseObserver.onCompleted() + } + } + } + + private companion object { + const val TAG = "SampleGrpcServer" + } +} diff --git a/sample/src/main/kotlin/com/chuckerteam/chucker/sample/compose/ChuckerSampleControls.kt b/sample/src/main/kotlin/com/chuckerteam/chucker/sample/compose/ChuckerSampleControls.kt index 30b6291da..763a2fa30 100644 --- a/sample/src/main/kotlin/com/chuckerteam/chucker/sample/compose/ChuckerSampleControls.kt +++ b/sample/src/main/kotlin/com/chuckerteam/chucker/sample/compose/ChuckerSampleControls.kt @@ -38,6 +38,7 @@ import com.chuckerteam.chucker.sample.compose.theme.ChuckerTheme * @param onInterceptorTypeChange called when user selects a different interceptor. * @param onInterceptorTypeLabelClick called when the interceptor label is clicked. * @param onDoHttp performs an HTTP call. + * @param onDoGrpc performs a gRPC call. * @param onDoGraphQL performs a GraphQL call. * @param onLaunchChucker launches Chucker UI directly. * @param onExportToLogFile exports logs to file. @@ -50,6 +51,7 @@ internal fun ChuckerSampleControls( onInterceptorTypeChange: (InterceptorType) -> Unit, onInterceptorTypeLabelClick: () -> Unit, onDoHttp: () -> Unit, + onDoGrpc: () -> Unit, onDoGraphQL: () -> Unit, onLaunchChucker: () -> Unit, onExportToLogFile: () -> Unit, @@ -109,11 +111,13 @@ internal fun ChuckerSampleControls( val buttonTags = listOf( ChuckerTestTags.CONTROLS_DO_HTTP_BUTTON, + ChuckerTestTags.CONTROLS_DO_GRPC_BUTTON, ChuckerTestTags.CONTROLS_DO_GRAPHQL_BUTTON, ) listOf( stringResource(R.string.do_http_activity) to onDoHttp, + stringResource(R.string.do_grpc_activity) to onDoGrpc, stringResource(R.string.do_graphql_activity) to onDoGraphQL, ).forEachIndexed { index, (label, action) -> Button( @@ -216,6 +220,9 @@ private fun ChuckerSampleControlsPreview() { onDoHttp = { // DO Nothing }, + onDoGrpc = { + // DO Nothing + }, onDoGraphQL = { // DO Nothing }, @@ -264,6 +271,9 @@ private fun ChuckerSampleControlsTabletPreview() { onDoHttp = { // DO Nothing }, + onDoGrpc = { + // DO Nothing + }, onDoGraphQL = { // DO Nothing }, diff --git a/sample/src/main/kotlin/com/chuckerteam/chucker/sample/compose/ChuckerSampleMainScreen.kt b/sample/src/main/kotlin/com/chuckerteam/chucker/sample/compose/ChuckerSampleMainScreen.kt index 5c1f09af2..88ac17d31 100644 --- a/sample/src/main/kotlin/com/chuckerteam/chucker/sample/compose/ChuckerSampleMainScreen.kt +++ b/sample/src/main/kotlin/com/chuckerteam/chucker/sample/compose/ChuckerSampleMainScreen.kt @@ -46,6 +46,7 @@ import com.chuckerteam.chucker.sample.compose.theme.ChuckerTheme * @param onInterceptorTypeChange Callback when a new interceptor type is selected by the user. * @param onInterceptorTypeLabelClick Callback when the interceptor type label is clicked. * @param onDoHttp Called to perform a sample HTTP request. + * @param onDoGrpc Called to perform a sample gRPC request. * @param onDoGraphQL Called to perform a sample GraphQL request. * @param onLaunchChucker Called to open the Chucker transaction list UI. * @param onExportToLogFile Called to export network logs to a plaintext file. @@ -60,6 +61,7 @@ internal fun ChuckerSampleMainScreen( onInterceptorTypeChange: (InterceptorType) -> Unit, onInterceptorTypeLabelClick: () -> Unit, onDoHttp: () -> Unit, + onDoGrpc: () -> Unit, onDoGraphQL: () -> Unit, onLaunchChucker: () -> Unit, onExportToLogFile: () -> Unit, @@ -110,6 +112,7 @@ internal fun ChuckerSampleMainScreen( onInterceptorTypeChange = onInterceptorTypeChange, onInterceptorTypeLabelClick = onInterceptorTypeLabelClick, onDoHttp = onDoHttp, + onDoGrpc = onDoGrpc, onDoGraphQL = onDoGraphQL, onLaunchChucker = onLaunchChucker, onExportToLogFile = onExportToLogFile, @@ -158,6 +161,7 @@ internal fun ChuckerSampleMainScreen( onInterceptorTypeChange = onInterceptorTypeChange, onInterceptorTypeLabelClick = onInterceptorTypeLabelClick, onDoHttp = onDoHttp, + onDoGrpc = onDoGrpc, onDoGraphQL = onDoGraphQL, onLaunchChucker = onLaunchChucker, onExportToLogFile = onExportToLogFile, @@ -215,6 +219,9 @@ private fun ChuckerSampleMainScreenPreview() { onDoHttp = { // DO Nothing }, + onDoGrpc = { + // DO Nothing + }, onDoGraphQL = { // DO Nothing }, @@ -261,6 +268,9 @@ private fun ChuckerSampleMainScreenTabletPreview() { onDoHttp = { // DO Nothing }, + onDoGrpc = { + // DO Nothing + }, onDoGraphQL = { // DO Nothing }, diff --git a/sample/src/main/kotlin/com/chuckerteam/chucker/sample/compose/testtags/ChuckerTestTags.kt b/sample/src/main/kotlin/com/chuckerteam/chucker/sample/compose/testtags/ChuckerTestTags.kt index a2af91b9c..49961c8f5 100644 --- a/sample/src/main/kotlin/com/chuckerteam/chucker/sample/compose/testtags/ChuckerTestTags.kt +++ b/sample/src/main/kotlin/com/chuckerteam/chucker/sample/compose/testtags/ChuckerTestTags.kt @@ -8,6 +8,7 @@ object ChuckerTestTags { const val CONTROLS_EXPORT_LOG_BUTTON = "controls_export_log_button" const val CONTROLS_EXPORT_HAR_BUTTON = "controls_export_har_button" const val CONTROLS_DO_HTTP_BUTTON = "controls_do_http_button" + const val CONTROLS_DO_GRPC_BUTTON = "controls_do_grpc_button" const val CONTROLS_DO_GRAPHQL_BUTTON = "controls_do_graphql_button" const val LABELED_RADIO_BUTTON_ROW = "labeled_radio_button_row" const val LABELED_RADIO_BUTTON_LABEL_TEXT = "labeled_radio_button_label_text" diff --git a/sample/src/main/proto/sample_service.proto b/sample/src/main/proto/sample_service.proto new file mode 100644 index 000000000..28ddbff60 --- /dev/null +++ b/sample/src/main/proto/sample_service.proto @@ -0,0 +1,21 @@ +syntax = "proto3"; + +package com.chuckerteam.chucker.sample; + +option java_package = "com.chuckerteam.chucker.sample.grpc"; +option java_multiple_files = true; + +service SampleGreeter { + rpc SayHello (HelloRequest) returns (HelloReply); + rpc SayHelloServerStream (HelloRequest) returns (stream HelloReply); + rpc SayHelloClientStream (stream HelloRequest) returns (HelloReply); + rpc SayHelloBidiStream (stream HelloRequest) returns (stream HelloReply); +} + +message HelloRequest { + string name = 1; +} + +message HelloReply { + string message = 1; +} diff --git a/sample/src/main/res/values/strings.xml b/sample/src/main/res/values/strings.xml index 557b3e9d0..48f1157d5 100644 --- a/sample/src/main/res/values/strings.xml +++ b/sample/src/main/res/values/strings.xml @@ -4,6 +4,7 @@ Application Network Do HTTP activity + Do gRPC activity Do GraphQL activity Launch Chucker directly Export to LOG file From f04baa49881ab2b109aa8ee661991e59c7fba9d0 Mon Sep 17 00:00:00 2001 From: "M. Reza Nasirloo" Date: Thu, 21 May 2026 16:16:34 +0200 Subject: [PATCH 2/3] fix(sample): use create() instead of id() in protobuf DSL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit protobuf-gradle-plugin 0.9.x Kotlin DSL requires create() not id(); the id() extension needs an import that was unavailable after cleanup. Also drops redundant id("java") — Android handles javalite automatically. Co-Authored-By: Claude Sonnet 4.6 --- sample/build.gradle.kts | 23 ++++++----------------- 1 file changed, 6 insertions(+), 17 deletions(-) diff --git a/sample/build.gradle.kts b/sample/build.gradle.kts index a3d41369d..7cf530b81 100644 --- a/sample/build.gradle.kts +++ b/sample/build.gradle.kts @@ -1,5 +1,3 @@ -import com.google.protobuf.gradle.id - plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.android) @@ -69,30 +67,21 @@ android { warningsAsErrors = true } - sourceSets { - named("main") { - java { - srcDir("build/generated/source/proto/main/grpc") - srcDir("build/generated/source/proto/main/java") - } - } - } } protobuf { protoc { - artifact = libs.protoc.get().toString() + artifact = "com.google.protobuf:protoc:${libs.versions.protobuf.get()}" } plugins { - id("grpc") { - artifact = libs.protoc.gen.grpc.java.get().toString() + create("grpc") { + artifact = "io.grpc:protoc-gen-grpc-java:${libs.versions.grpc.get()}" } } generateProtoTasks { - all().forEach { - it.plugins { - id("java") - id("grpc") + all().forEach { task -> + task.plugins { + create("grpc") } } } From c93f9e8cd32fb34e72876d8a2320aa85f7d85298 Mon Sep 17 00:00:00 2001 From: "M. Reza Nasirloo" Date: Thu, 21 May 2026 17:01:33 +0200 Subject: [PATCH 3/3] fix(sample): isolate gRPC protos from Wire to prevent duplicate classes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire owns src/main/proto (pokemon.proto → Kotlin via Wire runtime). Protobuf-gradle-plugin must not overlap that directory or both tools generate classes in the same package, causing duplicate-class errors. - Move sample_service.proto to src/main/grpc (protobuf-plugin's dir) - Redirect protobuf-plugin Android source set proto dirs to src/main/grpc via configure so Wire never sees the gRPC proto - Add builtins { java } to generate HelloRequest/HelloReply message classes - Add grpc-protobuf dep (ProtoUtils marshaller used by generated stubs) - Add javax.annotation-api (compileOnly) — @Generated removed from JDK 9+ - Exclude protobuf-javalite globally: play-services-cronet pulls in an old version that conflicts with protobuf-java (which is a superset) - Fix detekt: extract magic numbers to constants, narrow exception types, suppress LongParameterList on Composables (known Compose pattern) - Fix ktlint style violations Co-Authored-By: Claude Sonnet 4.6 --- gradle/libs.versions.toml | 2 + .../chucker/api/ChuckerGrpcInterceptor.kt | 1 - .../chucker/api/ChuckerGrpcInterceptor.kt | 32 +++-- sample/build.gradle.kts | 23 +++ .../main/{proto => grpc}/sample_service.proto | 0 .../chuckerteam/chucker/sample/GrpcTask.kt | 134 +++++++++++------- .../chucker/sample/SampleApplication.kt | 1 - .../chucker/sample/SampleGrpcServer.kt | 48 ++++--- .../sample/compose/ChuckerSampleControls.kt | 1 + .../sample/compose/ChuckerSampleMainScreen.kt | 1 + 10 files changed, 160 insertions(+), 83 deletions(-) rename sample/src/main/{proto => grpc}/sample_service.proto (100%) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index cae50e154..b8460d3b9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -137,9 +137,11 @@ grpc-api = { group = "io.grpc", name = "grpc-api", version.ref = "grpc" } grpc-stub = { group = "io.grpc", name = "grpc-stub", version.ref = "grpc" } grpc-okhttp = { group = "io.grpc", name = "grpc-okhttp", version.ref = "grpc" } grpc-netty-shaded = { group = "io.grpc", name = "grpc-netty-shaded", version.ref = "grpc" } +grpc-protobuf = { group = "io.grpc", name = "grpc-protobuf", version.ref = "grpc" } protobuf-java = { group = "com.google.protobuf", name = "protobuf-java", version.ref = "protobuf" } protoc = { group = "com.google.protobuf", name = "protoc", version.ref = "protobuf" } protoc-gen-grpc-java = { group = "io.grpc", name = "protoc-gen-grpc-java", version.ref = "grpc" } +javax-annotation-api = { group = "javax.annotation", name = "javax.annotation-api", version = "1.3.2" } [plugins] android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" } diff --git a/library-no-op/src/main/kotlin/com/chuckerteam/chucker/api/ChuckerGrpcInterceptor.kt b/library-no-op/src/main/kotlin/com/chuckerteam/chucker/api/ChuckerGrpcInterceptor.kt index 97d6bd97b..efd19791c 100644 --- a/library-no-op/src/main/kotlin/com/chuckerteam/chucker/api/ChuckerGrpcInterceptor.kt +++ b/library-no-op/src/main/kotlin/com/chuckerteam/chucker/api/ChuckerGrpcInterceptor.kt @@ -18,7 +18,6 @@ public class ChuckerGrpcInterceptor private constructor( @Suppress("UNUSED_PARAMETER") private val maxContentLength: Long, @Suppress("UNUSED_PARAMETER") private val headersToRedact: Set, ) : ClientInterceptor { - public constructor( collector: ChuckerCollector, @Suppress("UNUSED_PARAMETER") context: Context, diff --git a/library/src/main/kotlin/com/chuckerteam/chucker/api/ChuckerGrpcInterceptor.kt b/library/src/main/kotlin/com/chuckerteam/chucker/api/ChuckerGrpcInterceptor.kt index c4fc1d542..47a8f1919 100644 --- a/library/src/main/kotlin/com/chuckerteam/chucker/api/ChuckerGrpcInterceptor.kt +++ b/library/src/main/kotlin/com/chuckerteam/chucker/api/ChuckerGrpcInterceptor.kt @@ -44,7 +44,6 @@ public class ChuckerGrpcInterceptor private constructor( private val maxContentLength: Long, private val headersToRedact: Set, ) : ClientInterceptor { - public constructor( collector: ChuckerCollector, @Suppress("UNUSED_PARAMETER") context: Context, @@ -65,7 +64,7 @@ public class ChuckerGrpcInterceptor private constructor( val authority = next.authority() ?: "unknown" transaction.host = authority.substringBefore(":") transaction.path = "/${method.fullMethodName}" - transaction.scheme = if (authority.substringAfterLast(':').toIntOrNull() == 443) "https" else "http" + transaction.scheme = if (authority.substringAfterLast(':').toIntOrNull() == HTTPS_PORT) "https" else "http" transaction.url = "${transaction.scheme}://$authority${transaction.path}" // Guards to ensure onRequestSent is called exactly once before any onResponseReceived. @@ -95,7 +94,10 @@ public class ChuckerGrpcInterceptor private constructor( return object : ForwardingClientCall.SimpleForwardingClientCall( next.newCall(method, callOptions), ) { - override fun start(responseListener: Listener, headers: Metadata) { + override fun start( + responseListener: Listener, + headers: Metadata, + ) { transaction.setRequestHeaders(headers.toHttpHeaders(headersToRedact)) transaction.requestContentType = "application/grpc" @@ -126,12 +128,16 @@ public class ChuckerGrpcInterceptor private constructor( super.onMessage(message) } - override fun onClose(status: Status, trailers: Metadata) { + override fun onClose( + status: Status, + trailers: Metadata, + ) { transaction.responseCode = status.code.value() - transaction.responseMessage = buildString { - append(status.code.name) - status.description?.let { append(" ($it)") } - } + transaction.responseMessage = + buildString { + append(status.code.name) + status.description?.let { append(" ($it)") } + } status.cause?.let { transaction.error = it.toString() } transaction.appendResponseTrailers(trailers.toHttpHeaders(headersToRedact)) transaction.tookMs = @@ -202,6 +208,12 @@ public class ChuckerGrpcInterceptor private constructor( ) } - private fun String.truncatedTo(actualSize: Long, max: Long): String = - if (actualSize > max) take(max.toInt()) + "\n\n--- (truncated)" else this + private fun String.truncatedTo( + actualSize: Long, + max: Long, + ): String = if (actualSize > max) take(max.toInt()) + "\n\n--- (truncated)" else this + + private companion object { + const val HTTPS_PORT = 443 + } } diff --git a/sample/build.gradle.kts b/sample/build.gradle.kts index 7cf530b81..2ec4197e9 100644 --- a/sample/build.gradle.kts +++ b/sample/build.gradle.kts @@ -66,7 +66,19 @@ android { disable.addAll(listOf("AcceptsUserCertificates", "GradleDependency")) warningsAsErrors = true } +} +// Wire owns src/main/proto (pokemon.proto). Redirect protobuf-plugin to src/main/grpc so the two +// tools never process the same files, avoiding duplicate-class errors (Wire→Kotlin, protoc→Java). +configure { + sourceSets.getByName("main") { + @Suppress("UNCHECKED_CAST") + val proto = + (this as org.gradle.api.plugins.ExtensionAware) + .extensions + .getByName("proto") as org.gradle.api.file.SourceDirectorySet + proto.setSrcDirs(listOf(file("src/main/grpc"))) + } } protobuf { @@ -80,6 +92,9 @@ protobuf { } generateProtoTasks { all().forEach { task -> + task.builtins { + create("java") + } task.plugins { create("grpc") } @@ -97,6 +112,12 @@ apollo { } } +// protobuf-java (full) is a superset of protobuf-javalite. play-services-cronet pulls in an older +// javalite that conflicts with our javalite classes bundled inside protobuf-java. Exclude it. +configurations.all { + exclude(group = "com.google.protobuf", module = "protobuf-javalite") +} + dependencies { androidTestImplementation(libs.androidx.ui.test.junit4) androidTestImplementation(libs.androidx.junit) @@ -132,8 +153,10 @@ dependencies { debugImplementation(libs.leakcanary.android) // gRPC for demo purposes + compileOnly(libs.javax.annotation.api) implementation(libs.grpc.okhttp) implementation(libs.grpc.stub) + implementation(libs.grpc.protobuf) implementation(libs.grpc.netty.shaded) implementation(libs.protobuf.java) } diff --git a/sample/src/main/proto/sample_service.proto b/sample/src/main/grpc/sample_service.proto similarity index 100% rename from sample/src/main/proto/sample_service.proto rename to sample/src/main/grpc/sample_service.proto diff --git a/sample/src/main/kotlin/com/chuckerteam/chucker/sample/GrpcTask.kt b/sample/src/main/kotlin/com/chuckerteam/chucker/sample/GrpcTask.kt index e0d23b296..9c810a5ca 100644 --- a/sample/src/main/kotlin/com/chuckerteam/chucker/sample/GrpcTask.kt +++ b/sample/src/main/kotlin/com/chuckerteam/chucker/sample/GrpcTask.kt @@ -3,9 +3,11 @@ package com.chuckerteam.chucker.sample import android.content.Context import android.util.Log import com.chuckerteam.chucker.api.ChuckerGrpcInterceptor +import com.chuckerteam.chucker.sample.grpc.HelloReply import com.chuckerteam.chucker.sample.grpc.HelloRequest import com.chuckerteam.chucker.sample.grpc.SampleGreeterGrpc import io.grpc.ManagedChannel +import io.grpc.StatusRuntimeException import io.grpc.okhttp.OkHttpChannelBuilder import io.grpc.stub.StreamObserver import kotlinx.coroutines.CoroutineScope @@ -18,35 +20,39 @@ import kotlinx.coroutines.launch import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit -internal class GrpcTask(context: Context) { - +internal class GrpcTask( + context: Context, +) { private val job = Job() private val scope = CoroutineScope(Dispatchers.IO + job) - private val interceptor = ChuckerGrpcInterceptor( - collector = (context.applicationContext as SampleApplication).chuckerCollector, - context = context, - ) + private val interceptor = + ChuckerGrpcInterceptor( + collector = (context.applicationContext as SampleApplication).chuckerCollector, + context = context, + ) private lateinit var channel: ManagedChannel fun execute() { scope.launch { - channel = OkHttpChannelBuilder.forAddress("localhost", SampleApplication.GRPC_PORT) - .usePlaintext() - .intercept(interceptor) - .build() + channel = + OkHttpChannelBuilder + .forAddress("localhost", SampleApplication.GRPC_PORT) + .usePlaintext() + .intercept(interceptor) + .build() val blocking = SampleGreeterGrpc.newBlockingStub(channel) val async = SampleGreeterGrpc.newStub(channel) runUnary(blocking) - delay(500) + delay(CALL_DELAY_MS) runServerStream(blocking) - delay(500) + delay(CALL_DELAY_MS) runClientStream(async) - delay(500) + delay(CALL_DELAY_MS) runBidiStream(async) - channel.shutdown().awaitTermination(5, TimeUnit.SECONDS) + channel.shutdown().awaitTermination(SHUTDOWN_TIMEOUT_SEC, TimeUnit.SECONDS) } } @@ -61,77 +67,97 @@ internal class GrpcTask(context: Context) { try { val reply = stub.sayHello(HelloRequest.newBuilder().setName("UnaryUser").build()) Log.i(TAG, "Unary: ${reply.message}") - } catch (e: Exception) { + } catch (e: StatusRuntimeException) { Log.e(TAG, "Unary failed", e) } } private fun runServerStream(stub: SampleGreeterGrpc.SampleGreeterBlockingStub) { try { - stub.sayHelloServerStream(HelloRequest.newBuilder().setName("StreamUser").build()) + stub + .sayHelloServerStream(HelloRequest.newBuilder().setName("StreamUser").build()) .forEach { Log.i(TAG, "ServerStream: ${it.message}") } - } catch (e: Exception) { + } catch (e: StatusRuntimeException) { Log.e(TAG, "ServerStream failed", e) } } private suspend fun runClientStream(stub: SampleGreeterGrpc.SampleGreeterStub) { val latch = CountDownLatch(1) - val observer = stub.sayHelloClientStream(object : StreamObserver { - override fun onNext(value: com.chuckerteam.chucker.sample.grpc.HelloReply) { - Log.i(TAG, "ClientStream: ${value.message}") - } - - override fun onError(t: Throwable) { - Log.e(TAG, "ClientStream error", t) - latch.countDown() - } - - override fun onCompleted() { - latch.countDown() - } - }) + val observer = + stub.sayHelloClientStream( + object : StreamObserver { + override fun onNext(value: HelloReply) { + Log.i(TAG, "ClientStream: ${value.message}") + } + + override fun onError(t: Throwable) { + Log.e(TAG, "ClientStream error", t) + latch.countDown() + } + + override fun onCompleted() { + latch.countDown() + } + }, + ) try { listOf("User1", "User2", "User3").forEach { observer.onNext(HelloRequest.newBuilder().setName(it).build()) - delay(200) + delay(STREAM_MESSAGE_DELAY_MS) } observer.onCompleted() - latch.await(10, TimeUnit.SECONDS) - } catch (e: Exception) { + latch.await(AWAIT_TIMEOUT_SEC, TimeUnit.SECONDS) + } catch (e: StatusRuntimeException) { Log.e(TAG, "ClientStream failed", e) + } catch (e: InterruptedException) { + Log.e(TAG, "ClientStream interrupted", e) + Thread.currentThread().interrupt() } } private suspend fun runBidiStream(stub: SampleGreeterGrpc.SampleGreeterStub) { val latch = CountDownLatch(1) - val observer = stub.sayHelloBidiStream(object : StreamObserver { - override fun onNext(value: com.chuckerteam.chucker.sample.grpc.HelloReply) { - Log.i(TAG, "BidiStream: ${value.message}") - } - - override fun onError(t: Throwable) { - Log.e(TAG, "BidiStream error", t) - latch.countDown() - } - - override fun onCompleted() { - latch.countDown() - } - }) + val observer = + stub.sayHelloBidiStream( + object : StreamObserver { + override fun onNext(value: HelloReply) { + Log.i(TAG, "BidiStream: ${value.message}") + } + + override fun onError(t: Throwable) { + Log.e(TAG, "BidiStream error", t) + latch.countDown() + } + + override fun onCompleted() { + latch.countDown() + } + }, + ) try { - listOf("BidiUser1", "BidiUser2", "BidiUser3").asFlow().map { - delay(300) - HelloRequest.newBuilder().setName(it).build() - }.collect { observer.onNext(it) } + listOf("BidiUser1", "BidiUser2", "BidiUser3") + .asFlow() + .map { + delay(BIDI_MESSAGE_DELAY_MS) + HelloRequest.newBuilder().setName(it).build() + }.collect { observer.onNext(it) } observer.onCompleted() - latch.await(10, TimeUnit.SECONDS) - } catch (e: Exception) { + latch.await(AWAIT_TIMEOUT_SEC, TimeUnit.SECONDS) + } catch (e: StatusRuntimeException) { Log.e(TAG, "BidiStream failed", e) + } catch (e: InterruptedException) { + Log.e(TAG, "BidiStream interrupted", e) + Thread.currentThread().interrupt() } } private companion object { const val TAG = "GrpcTask" + const val CALL_DELAY_MS = 500L + const val STREAM_MESSAGE_DELAY_MS = 200L + const val BIDI_MESSAGE_DELAY_MS = 300L + const val AWAIT_TIMEOUT_SEC = 10L + const val SHUTDOWN_TIMEOUT_SEC = 5L } } diff --git a/sample/src/main/kotlin/com/chuckerteam/chucker/sample/SampleApplication.kt b/sample/src/main/kotlin/com/chuckerteam/chucker/sample/SampleApplication.kt index c22a2a3cb..5bb1d6adc 100644 --- a/sample/src/main/kotlin/com/chuckerteam/chucker/sample/SampleApplication.kt +++ b/sample/src/main/kotlin/com/chuckerteam/chucker/sample/SampleApplication.kt @@ -9,7 +9,6 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch class SampleApplication : Application() { - private val serverScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private val grpcServer by lazy { SampleGrpcServer(GRPC_PORT) } diff --git a/sample/src/main/kotlin/com/chuckerteam/chucker/sample/SampleGrpcServer.kt b/sample/src/main/kotlin/com/chuckerteam/chucker/sample/SampleGrpcServer.kt index 976c6d080..453480c32 100644 --- a/sample/src/main/kotlin/com/chuckerteam/chucker/sample/SampleGrpcServer.kt +++ b/sample/src/main/kotlin/com/chuckerteam/chucker/sample/SampleGrpcServer.kt @@ -12,10 +12,12 @@ import kotlinx.coroutines.asExecutor import java.io.IOException import java.util.concurrent.TimeUnit -internal class SampleGrpcServer(private val port: Int) { - +internal class SampleGrpcServer( + private val port: Int, +) { private val server: Server by lazy { - NettyServerBuilder.forPort(port) + NettyServerBuilder + .forPort(port) .addService(GreeterService()) .executor(Dispatchers.IO.asExecutor()) .build() @@ -32,7 +34,7 @@ internal class SampleGrpcServer(private val port: Int) { fun stop() { try { - server.shutdown().awaitTermination(5, TimeUnit.SECONDS) + server.shutdown().awaitTermination(SHUTDOWN_TIMEOUT_SEC, TimeUnit.SECONDS) Log.i(TAG, "Server stopped") } catch (e: InterruptedException) { Log.w(TAG, "Shutdown interrupted, forcing stop", e) @@ -42,25 +44,28 @@ internal class SampleGrpcServer(private val port: Int) { } private class GreeterService : SampleGreeterGrpc.SampleGreeterImplBase() { - - override fun sayHello(req: HelloRequest, responseObserver: StreamObserver) { + override fun sayHello( + req: HelloRequest, + responseObserver: StreamObserver, + ) { responseObserver.onNext(HelloReply.newBuilder().setMessage("Hello ${req.name} (Unary)").build()) responseObserver.onCompleted() } - override fun sayHelloServerStream(req: HelloRequest, responseObserver: StreamObserver) { - repeat(3) { i -> + override fun sayHelloServerStream( + req: HelloRequest, + responseObserver: StreamObserver, + ) { + repeat(SERVER_STREAM_REPLIES) { i -> responseObserver.onNext( HelloReply.newBuilder().setMessage("Hello ${req.name}, part ${i + 1} (Server Stream)").build(), ) - Thread.sleep(300) + Thread.sleep(STREAM_REPLY_DELAY_MS) } responseObserver.onCompleted() } - override fun sayHelloClientStream( - responseObserver: StreamObserver, - ): StreamObserver { + override fun sayHelloClientStream(responseObserver: StreamObserver): StreamObserver { val names = StringBuilder() return object : StreamObserver { override fun onNext(value: HelloRequest) { @@ -69,7 +74,11 @@ internal class SampleGrpcServer(private val port: Int) { } override fun onError(t: Throwable) { - responseObserver.onError(io.grpc.Status.fromThrowable(t).asRuntimeException()) + responseObserver.onError( + io.grpc.Status + .fromThrowable(t) + .asRuntimeException(), + ) } override fun onCompleted() { @@ -79,9 +88,7 @@ internal class SampleGrpcServer(private val port: Int) { } } - override fun sayHelloBidiStream( - responseObserver: StreamObserver, - ): StreamObserver = + override fun sayHelloBidiStream(responseObserver: StreamObserver): StreamObserver = object : StreamObserver { override fun onNext(value: HelloRequest) { responseObserver.onNext( @@ -90,7 +97,11 @@ internal class SampleGrpcServer(private val port: Int) { } override fun onError(t: Throwable) { - responseObserver.onError(io.grpc.Status.fromThrowable(t).asRuntimeException()) + responseObserver.onError( + io.grpc.Status + .fromThrowable(t) + .asRuntimeException(), + ) } override fun onCompleted() { @@ -101,5 +112,8 @@ internal class SampleGrpcServer(private val port: Int) { private companion object { const val TAG = "SampleGrpcServer" + const val SHUTDOWN_TIMEOUT_SEC = 5L + const val SERVER_STREAM_REPLIES = 3 + const val STREAM_REPLY_DELAY_MS = 300L } } diff --git a/sample/src/main/kotlin/com/chuckerteam/chucker/sample/compose/ChuckerSampleControls.kt b/sample/src/main/kotlin/com/chuckerteam/chucker/sample/compose/ChuckerSampleControls.kt index 763a2fa30..604540508 100644 --- a/sample/src/main/kotlin/com/chuckerteam/chucker/sample/compose/ChuckerSampleControls.kt +++ b/sample/src/main/kotlin/com/chuckerteam/chucker/sample/compose/ChuckerSampleControls.kt @@ -45,6 +45,7 @@ import com.chuckerteam.chucker.sample.compose.theme.ChuckerTheme * @param onExportToHarFile exports HAR to file. * @param isChuckerInOpMode controls visibility of Chucker-specific operations. */ +@Suppress("LongParameterList") @Composable internal fun ChuckerSampleControls( selectedInterceptorType: InterceptorType, diff --git a/sample/src/main/kotlin/com/chuckerteam/chucker/sample/compose/ChuckerSampleMainScreen.kt b/sample/src/main/kotlin/com/chuckerteam/chucker/sample/compose/ChuckerSampleMainScreen.kt index 88ac17d31..ed0ec6089 100644 --- a/sample/src/main/kotlin/com/chuckerteam/chucker/sample/compose/ChuckerSampleMainScreen.kt +++ b/sample/src/main/kotlin/com/chuckerteam/chucker/sample/compose/ChuckerSampleMainScreen.kt @@ -53,6 +53,7 @@ import com.chuckerteam.chucker.sample.compose.theme.ChuckerTheme * @param onExportToHarFile Called to export network logs to a HAR (HTTP Archive) file. * @param isChuckerInOpMode If true, displays the Chucker-specific operation buttons. */ +@Suppress("LongParameterList") @OptIn(ExperimentalMaterial3Api::class) @Composable internal fun ChuckerSampleMainScreen(