From e29d6b74112270be663495c78fd537da8b5acff4 Mon Sep 17 00:00:00 2001 From: simonvar Date: Mon, 13 Jul 2026 15:47:32 +0300 Subject: [PATCH 1/2] Add WS --- .../workflows/publish-github-packages.yaml | 45 +++++ gradle/gradle-github-packages.gradle | 26 +++ library-no-op/build.gradle.kts | 1 + .../chucker/api/ChuckerWebSocketListener.kt | 53 ++++++ .../chucker/api/ChuckerWebSockets.kt | 22 +++ library/build.gradle.kts | 1 + library/src/main/AndroidManifest.xml | 5 + .../chucker/api/ChuckerCollector.kt | 34 +++- .../chucker/api/ChuckerWebSocketListener.kt | 98 +++++++++++ .../chucker/api/ChuckerWebSockets.kt | 50 ++++++ .../chucker/api/RetentionManager.kt | 1 + .../internal/data/entity/WebSocketTraffic.kt | 117 +++++++++++++ .../data/entity/WebSocketTrafficTuple.kt | 65 ++++++++ .../data/repository/RepositoryProvider.kt | 10 +- .../WebSocketTrafficDatabaseRepository.kt | 35 ++++ .../repository/WebSocketTrafficRepository.kt | 25 +++ .../internal/data/room/ChuckerDatabase.kt | 5 +- .../internal/data/room/WebSocketTrafficDao.kt | 36 ++++ .../internal/support/NotificationHelper.kt | 110 +++++++++---- .../internal/support/RecordingWebSocket.kt | 35 ++++ .../internal/support/TrafficListSharable.kt | 42 +++++ .../support/WebSocketTrafficRecorder.kt | 143 ++++++++++++++++ .../support/WebSocketTrafficSharable.kt | 32 ++++ .../chucker/internal/ui/MainActivity.kt | 64 ++++++-- .../chucker/internal/ui/MainViewModel.kt | 84 +++++++++- .../internal/ui/model/TransactionRow.kt | 57 +++++++ .../ui/transaction/TransactionAdapter.kt | 154 ++++++++++-------- .../ui/websocket/WebSocketTrafficActivity.kt | 83 ++++++++++ .../ui/websocket/WebSocketTrafficViewModel.kt | 23 +++ .../main/res/layout/chucker_activity_main.xml | 58 ++++++- .../chucker_activity_websocket_traffic.xml | 63 +++++++ .../layout/chucker_list_item_websocket.xml | 82 ++++++++++ library/src/main/res/values-es/strings.xml | 5 + library/src/main/res/values-ja/strings.xml | 5 + library/src/main/res/values-ko/strings.xml | 5 + library/src/main/res/values-ru/strings.xml | 5 + library/src/main/res/values/strings.xml | 5 + .../api/ChuckerWebSocketListenerTest.kt | 110 +++++++++++++ .../WebSocketTrafficDatabaseRepositoryTest.kt | 85 ++++++++++ .../support/RecordingWebSocketTest.kt | 76 +++++++++ .../support/TrafficListSharableTest.kt | 78 +++++++++ .../chucker/internal/ui/MainViewModelTest.kt | 24 ++- .../chucker/sample/MainActivity.kt | 3 + .../chucker/sample/WebSocketTask.kt | 75 +++++++++ .../sample/compose/ChuckerSampleControls.kt | 15 ++ .../sample/compose/ChuckerSampleMainScreen.kt | 10 ++ .../compose/testtags/ChuckerTestTags.kt | 1 + sample/src/main/res/values/strings.xml | 1 + 48 files changed, 2037 insertions(+), 125 deletions(-) create mode 100644 .github/workflows/publish-github-packages.yaml create mode 100644 gradle/gradle-github-packages.gradle create mode 100644 library-no-op/src/main/kotlin/com/chuckerteam/chucker/api/ChuckerWebSocketListener.kt create mode 100644 library-no-op/src/main/kotlin/com/chuckerteam/chucker/api/ChuckerWebSockets.kt create mode 100644 library/src/main/kotlin/com/chuckerteam/chucker/api/ChuckerWebSocketListener.kt create mode 100644 library/src/main/kotlin/com/chuckerteam/chucker/api/ChuckerWebSockets.kt create mode 100644 library/src/main/kotlin/com/chuckerteam/chucker/internal/data/entity/WebSocketTraffic.kt create mode 100644 library/src/main/kotlin/com/chuckerteam/chucker/internal/data/entity/WebSocketTrafficTuple.kt create mode 100644 library/src/main/kotlin/com/chuckerteam/chucker/internal/data/repository/WebSocketTrafficDatabaseRepository.kt create mode 100644 library/src/main/kotlin/com/chuckerteam/chucker/internal/data/repository/WebSocketTrafficRepository.kt create mode 100644 library/src/main/kotlin/com/chuckerteam/chucker/internal/data/room/WebSocketTrafficDao.kt create mode 100644 library/src/main/kotlin/com/chuckerteam/chucker/internal/support/RecordingWebSocket.kt create mode 100644 library/src/main/kotlin/com/chuckerteam/chucker/internal/support/TrafficListSharable.kt create mode 100644 library/src/main/kotlin/com/chuckerteam/chucker/internal/support/WebSocketTrafficRecorder.kt create mode 100644 library/src/main/kotlin/com/chuckerteam/chucker/internal/support/WebSocketTrafficSharable.kt create mode 100644 library/src/main/kotlin/com/chuckerteam/chucker/internal/ui/model/TransactionRow.kt create mode 100644 library/src/main/kotlin/com/chuckerteam/chucker/internal/ui/websocket/WebSocketTrafficActivity.kt create mode 100644 library/src/main/kotlin/com/chuckerteam/chucker/internal/ui/websocket/WebSocketTrafficViewModel.kt create mode 100644 library/src/main/res/layout/chucker_activity_websocket_traffic.xml create mode 100644 library/src/main/res/layout/chucker_list_item_websocket.xml create mode 100644 library/src/test/kotlin/com/chuckerteam/chucker/api/ChuckerWebSocketListenerTest.kt create mode 100644 library/src/test/kotlin/com/chuckerteam/chucker/internal/data/repository/WebSocketTrafficDatabaseRepositoryTest.kt create mode 100644 library/src/test/kotlin/com/chuckerteam/chucker/internal/support/RecordingWebSocketTest.kt create mode 100644 library/src/test/kotlin/com/chuckerteam/chucker/internal/support/TrafficListSharableTest.kt create mode 100644 sample/src/main/kotlin/com/chuckerteam/chucker/sample/WebSocketTask.kt diff --git a/.github/workflows/publish-github-packages.yaml b/.github/workflows/publish-github-packages.yaml new file mode 100644 index 000000000..ea397a62b --- /dev/null +++ b/.github/workflows/publish-github-packages.yaml @@ -0,0 +1,45 @@ +name: Publish to GitHub Packages + +on: + # Publish whenever a GitHub Release is published. + release: + types: [published] + # Allow publishing the current VERSION_NAME manually from the Actions tab. + workflow_dispatch: + +jobs: + publish: + # Runs on forks only; the upstream repo publishes to Maven Central instead. + if: ${{ github.repository != 'ChuckerTeam/chucker' }} + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout Repo + uses: actions/checkout@v6 + + - name: Setup Java + uses: actions/setup-java@v5 + with: + distribution: 'zulu' + java-version: '21' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v6 + + - name: Publish library and library-no-op to GitHub Packages + # For a release, publish under the release tag (stripping a leading "v"), e.g. tag + # "v4.4.0-blink.1" -> version "4.4.0-blink.1". For a manual run, fall back to the + # VERSION_NAME declared in gradle.properties. + run: | + VERSION_ARG="" + TAG="${{ github.event.release.tag_name }}" + if [ -n "$TAG" ]; then + VERSION_ARG="-PVERSION_NAME=${TAG#v}" + fi + ./gradlew publishReleasePublicationToGitHubPackagesRepository $VERSION_ARG --no-parallel --stacktrace + env: + GPR_USER: ${{ github.actor }} + GPR_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/gradle/gradle-github-packages.gradle b/gradle/gradle-github-packages.gradle new file mode 100644 index 000000000..f516ed531 --- /dev/null +++ b/gradle/gradle-github-packages.gradle @@ -0,0 +1,26 @@ +// Fork-specific: publish the `release` publication to GitHub Packages. +// +// Applied on top of gradle-mvn-push.gradle (which defines the `release` MavenPublication and +// optional signing). This only adds a target repository, so the upstream Sonatype/Maven Central +// setup is left untouched. +// +// Credentials & target repo are resolved from (in order): +// - env GPR_USER / GPR_TOKEN (set by the publish workflow) +// - env GITHUB_ACTOR / GITHUB_TOKEN (auto-provided inside GitHub Actions) +// - gradle properties gpr.user / gpr.token (for local publishing, e.g. in ~/.gradle/gradle.properties) +// The repository slug comes from GITHUB_REPOSITORY ("owner/repo", auto-set in CI) and defaults to +// the fork's slug for local runs. + +publishing { + repositories { + maven { + name = "GitHubPackages" + def repoSlug = System.getenv("GITHUB_REPOSITORY") ?: (findProperty("GITHUB_REPOSITORY") ?: "simonvar/chucker") + url = uri("https://maven.pkg.github.com/${repoSlug}") + credentials { + username = System.getenv("GPR_USER") ?: System.getenv("GITHUB_ACTOR") ?: findProperty("gpr.user") + password = System.getenv("GPR_TOKEN") ?: System.getenv("GITHUB_TOKEN") ?: findProperty("gpr.token") + } + } + } +} diff --git a/library-no-op/build.gradle.kts b/library-no-op/build.gradle.kts index 90f73bffb..bd9dd9bb7 100644 --- a/library-no-op/build.gradle.kts +++ b/library-no-op/build.gradle.kts @@ -56,4 +56,5 @@ dependencies { } apply(from = rootProject.file("gradle/gradle-mvn-push.gradle")) +apply(from = rootProject.file("gradle/gradle-github-packages.gradle")) apply(from = rootProject.file("gradle/kotlin-static-analysis.gradle")) diff --git a/library-no-op/src/main/kotlin/com/chuckerteam/chucker/api/ChuckerWebSocketListener.kt b/library-no-op/src/main/kotlin/com/chuckerteam/chucker/api/ChuckerWebSocketListener.kt new file mode 100644 index 000000000..5f282ce6a --- /dev/null +++ b/library-no-op/src/main/kotlin/com/chuckerteam/chucker/api/ChuckerWebSocketListener.kt @@ -0,0 +1,53 @@ +package com.chuckerteam.chucker.api + +import okhttp3.Request +import okhttp3.Response +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import okio.ByteString + +/** + * No-op implementation. + */ +@Suppress("UnusedPrivateMember", "UNUSED_PARAMETER") +public class ChuckerWebSocketListener + @JvmOverloads + constructor( + private val delegate: WebSocketListener, + collector: ChuckerCollector, + request: Request, + maxContentLength: Long = 250_000L, + ) : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: Response, + ): Unit = delegate.onOpen(webSocket, response) + + override fun onMessage( + webSocket: WebSocket, + text: String, + ): Unit = delegate.onMessage(webSocket, text) + + override fun onMessage( + webSocket: WebSocket, + bytes: ByteString, + ): Unit = delegate.onMessage(webSocket, bytes) + + override fun onClosing( + webSocket: WebSocket, + code: Int, + reason: String, + ): Unit = delegate.onClosing(webSocket, code, reason) + + override fun onClosed( + webSocket: WebSocket, + code: Int, + reason: String, + ): Unit = delegate.onClosed(webSocket, code, reason) + + override fun onFailure( + webSocket: WebSocket, + t: Throwable, + response: Response?, + ): Unit = delegate.onFailure(webSocket, t, response) + } diff --git a/library-no-op/src/main/kotlin/com/chuckerteam/chucker/api/ChuckerWebSockets.kt b/library-no-op/src/main/kotlin/com/chuckerteam/chucker/api/ChuckerWebSockets.kt new file mode 100644 index 000000000..feaefa6b0 --- /dev/null +++ b/library-no-op/src/main/kotlin/com/chuckerteam/chucker/api/ChuckerWebSockets.kt @@ -0,0 +1,22 @@ +package com.chuckerteam.chucker.api + +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.WebSocket +import okhttp3.WebSocketListener + +/** + * No-op implementation. + */ +@Suppress("UnusedPrivateMember", "UNUSED_PARAMETER") +public object ChuckerWebSockets { + @JvmStatic + @JvmOverloads + public fun create( + client: OkHttpClient, + request: Request, + collector: ChuckerCollector, + listener: WebSocketListener, + maxContentLength: Long = 250_000L, + ): WebSocket = client.newWebSocket(request, listener) +} diff --git a/library/build.gradle.kts b/library/build.gradle.kts index 72c08662c..b76ad7be4 100644 --- a/library/build.gradle.kts +++ b/library/build.gradle.kts @@ -120,4 +120,5 @@ dependencies { } apply(from = rootProject.file("gradle/gradle-mvn-push.gradle")) +apply(from = rootProject.file("gradle/gradle-github-packages.gradle")) apply(from = rootProject.file("gradle/kotlin-static-analysis.gradle")) diff --git a/library/src/main/AndroidManifest.xml b/library/src/main/AndroidManifest.xml index 1ed9667ef..9580a8d0c 100644 --- a/library/src/main/AndroidManifest.xml +++ b/library/src/main/AndroidManifest.xml @@ -23,6 +23,11 @@ android:parentActivityName="com.chuckerteam.chucker.internal.ui.MainActivity" android:theme="@style/Chucker.Theme" /> + + { - TransactionListDetailsSharable(transactions, encodeUrls = false) + TrafficListSharable(transactions, webSocketTraffic) } ExportFormat.HAR -> { TransactionDetailsHarSharable( diff --git a/library/src/main/kotlin/com/chuckerteam/chucker/api/ChuckerWebSocketListener.kt b/library/src/main/kotlin/com/chuckerteam/chucker/api/ChuckerWebSocketListener.kt new file mode 100644 index 000000000..6f3d1f7ba --- /dev/null +++ b/library/src/main/kotlin/com/chuckerteam/chucker/api/ChuckerWebSocketListener.kt @@ -0,0 +1,98 @@ +package com.chuckerteam.chucker.api + +import com.chuckerteam.chucker.internal.support.WebSocketTrafficRecorder +import okhttp3.Response +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import okio.ByteString +import java.util.concurrent.atomic.AtomicLong + +/** + * A [WebSocketListener] decorator that records every incoming message and lifecycle event + * (open / closing / closed / failure) into Chucker before delegating to the wrapped [delegate]. + * + * OkHttp does not run application interceptors on the WebSocket frame stream, so this decorator + * (together with the [okhttp3.WebSocket] returned by [ChuckerWebSockets.create], which captures + * outgoing frames) is how Chucker observes WebSocket traffic. + * + * You normally do not need to instantiate this directly — use [ChuckerWebSockets.create], which + * wraps both the listener and the socket. Instantiate it directly only if you want to capture + * incoming + lifecycle events without capturing outgoing `send()` calls. + * + * @param delegate The original listener that should keep receiving all callbacks. + * @param collector The [ChuckerCollector] that persists the captured traffic. + * @param request The request used to open the WebSocket (provides url/host/path). + * @param maxContentLength Maximum number of characters stored per message. Defaults to 250000. + */ +public class ChuckerWebSocketListener + @JvmOverloads + constructor( + private val delegate: WebSocketListener, + collector: ChuckerCollector, + request: okhttp3.Request, + maxContentLength: Long = MAX_CONTENT_LENGTH, + ) : WebSocketListener() { + internal val recorder = + WebSocketTrafficRecorder( + request = request, + connectionId = connectionIdGenerator.incrementAndGet(), + collector = collector, + maxContentLength = maxContentLength, + ) + + override fun onOpen( + webSocket: WebSocket, + response: Response, + ) { + recorder.onOpen(response) + delegate.onOpen(webSocket, response) + } + + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + recorder.onReceiveText(text) + delegate.onMessage(webSocket, text) + } + + override fun onMessage( + webSocket: WebSocket, + bytes: ByteString, + ) { + recorder.onReceiveBytes(bytes) + delegate.onMessage(webSocket, bytes) + } + + override fun onClosing( + webSocket: WebSocket, + code: Int, + reason: String, + ) { + recorder.onClosing(code, reason) + delegate.onClosing(webSocket, code, reason) + } + + override fun onClosed( + webSocket: WebSocket, + code: Int, + reason: String, + ) { + recorder.onClosed(code, reason) + delegate.onClosed(webSocket, code, reason) + } + + override fun onFailure( + webSocket: WebSocket, + t: Throwable, + response: Response?, + ) { + recorder.onFailure(t, response) + delegate.onFailure(webSocket, t, response) + } + + public companion object { + private const val MAX_CONTENT_LENGTH = 250_000L + private val connectionIdGenerator = AtomicLong(0) + } + } diff --git a/library/src/main/kotlin/com/chuckerteam/chucker/api/ChuckerWebSockets.kt b/library/src/main/kotlin/com/chuckerteam/chucker/api/ChuckerWebSockets.kt new file mode 100644 index 000000000..363bc80e4 --- /dev/null +++ b/library/src/main/kotlin/com/chuckerteam/chucker/api/ChuckerWebSockets.kt @@ -0,0 +1,50 @@ +package com.chuckerteam.chucker.api + +import com.chuckerteam.chucker.internal.support.RecordingWebSocket +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.WebSocket +import okhttp3.WebSocketListener + +/** + * Entry point for capturing WebSocket traffic in Chucker. + * + * Use this instead of [OkHttpClient.newWebSocket] to have Chucker record the full lifecycle of a + * WebSocket connection — the handshake response, every incoming and outgoing message, and the + * closing / closed / failure events. + * + * ``` + * val webSocket = ChuckerWebSockets.create(client, request, collector, myListener) + * webSocket.send("hello") // recorded as an outgoing frame, then delivered to the server + * ``` + * + * The returned [WebSocket] is a thin wrapper around the real socket: calling `send(...)` records + * the frame and then delegates. `myListener` keeps receiving all of its normal callbacks. + */ +public object ChuckerWebSockets { + /** + * Opens a WebSocket whose traffic is recorded by Chucker. + * + * @param client The [OkHttpClient] used to open the socket. + * @param request The WebSocket request (ws:// or wss://). + * @param collector The [ChuckerCollector] that persists the captured traffic. + * @param listener The caller's [WebSocketListener]; it keeps receiving every callback. + * @param maxContentLength Maximum number of characters stored per message. Defaults to 250000. + * @return A [WebSocket] wrapper that records outgoing frames before delegating. + */ + @JvmStatic + @JvmOverloads + public fun create( + client: OkHttpClient, + request: Request, + collector: ChuckerCollector, + listener: WebSocketListener, + maxContentLength: Long = MAX_CONTENT_LENGTH, + ): WebSocket { + val chuckerListener = ChuckerWebSocketListener(listener, collector, request, maxContentLength) + val realWebSocket = client.newWebSocket(request, chuckerListener) + return RecordingWebSocket(realWebSocket, chuckerListener.recorder) + } + + private const val MAX_CONTENT_LENGTH = 250_000L +} diff --git a/library/src/main/kotlin/com/chuckerteam/chucker/api/RetentionManager.kt b/library/src/main/kotlin/com/chuckerteam/chucker/api/RetentionManager.kt index c7eb4103a..9aa5a680e 100644 --- a/library/src/main/kotlin/com/chuckerteam/chucker/api/RetentionManager.kt +++ b/library/src/main/kotlin/com/chuckerteam/chucker/api/RetentionManager.kt @@ -72,6 +72,7 @@ public class RetentionManager private suspend fun deleteSince(threshold: Long) { RepositoryProvider.transaction().deleteOldTransactions(threshold) + RepositoryProvider.webSocket().deleteOldWebSocketTraffic(threshold) } private fun isCleanupDue(now: Long) = now - getLastCleanup(now) > cleanupFrequency diff --git a/library/src/main/kotlin/com/chuckerteam/chucker/internal/data/entity/WebSocketTraffic.kt b/library/src/main/kotlin/com/chuckerteam/chucker/internal/data/entity/WebSocketTraffic.kt new file mode 100644 index 000000000..1334b7fa6 --- /dev/null +++ b/library/src/main/kotlin/com/chuckerteam/chucker/internal/data/entity/WebSocketTraffic.kt @@ -0,0 +1,117 @@ +package com.chuckerteam.chucker.internal.data.entity + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey +import com.chuckerteam.chucker.internal.support.FormatUtils +import java.util.Date + +/** + * Represents a single WebSocket event (one row per event) captured by Chucker. + * + * Unlike [HttpTransaction] which models a full request/response pair, WebSocket traffic is + * inherently a stream of independent events (open, messages in/out, closing, closed, failure). + * Each event is stored flat so it can be merged with HTTP transactions into a single + * chronological list. + */ +@Suppress("LongParameterList") +@Entity(tableName = "websocket_traffic") +internal class WebSocketTraffic( + @PrimaryKey(autoGenerate = true) + @ColumnInfo(name = "id") + var id: Long = 0, + @ColumnInfo(name = "timestamp") var timestamp: Long, + // Monotonic counter captured synchronously when the event is recorded. Used as a tiebreaker + // to keep events ordered deterministically when several share the same millisecond timestamp. + @ColumnInfo(name = "sequence") var sequence: Long = 0, + @ColumnInfo(name = "connectionId") var connectionId: Long, + @ColumnInfo(name = "type") var type: String, + @ColumnInfo(name = "url") var url: String?, + @ColumnInfo(name = "host") var host: String?, + @ColumnInfo(name = "path") var path: String?, + @ColumnInfo(name = "scheme") var scheme: String?, + @ColumnInfo(name = "content") var content: String?, + @ColumnInfo(name = "contentType") var contentType: String?, + @ColumnInfo(name = "size") var size: Long?, + @ColumnInfo(name = "code") var code: Int?, + @ColumnInfo(name = "error") var error: String?, +) { + enum class Type { + OPEN, + SEND, + RECEIVE, + CLOSING, + CLOSED, + FAILURE, + } + + enum class Direction { + OUTGOING, + INCOMING, + NONE, + } + + val typeEnum: Type + get() = runCatching { Type.valueOf(type) }.getOrDefault(Type.FAILURE) + + val direction: Direction + get() = + when (typeEnum) { + Type.SEND -> Direction.OUTGOING + Type.RECEIVE -> Direction.INCOMING + else -> Direction.NONE + } + + val timeString: String + get() = Date(timestamp).toString() + + val sizeString: String? + get() = size?.let { FormatUtils.formatByteCount(it, true) } + + val isSsl: Boolean + get() = scheme.equals("wss", ignoreCase = true) + + /** + * Short human readable label of the event type, used across the UI, notification and export. + */ + val typeLabel: String + get() = + when (typeEnum) { + Type.OPEN -> "OPEN" + Type.SEND -> "SENT" + Type.RECEIVE -> "RECEIVED" + Type.CLOSING -> "CLOSING" + Type.CLOSED -> "CLOSED" + Type.FAILURE -> "FAILURE" + } + + val notificationText: String + get() = + when (typeEnum) { + Type.OPEN -> "[WS] ↕ OPEN ${path.orEmpty()}" + Type.SEND -> "[WS] ↑ ${previewContent()}" + Type.RECEIVE -> "[WS] ↓ ${previewContent()}" + Type.CLOSING -> "[WS] × CLOSING $code ${content.orEmpty()}" + Type.CLOSED -> "[WS] × CLOSED $code ${content.orEmpty()}" + Type.FAILURE -> "[WS] ! FAILURE ${error.orEmpty()}" + } + + fun previewContent(maxLength: Int = PREVIEW_MAX_LENGTH): String { + val raw = content?.replace("\n", " ")?.trim().orEmpty() + return if (raw.length > maxLength) raw.take(maxLength) + "…" else raw + } + + fun getFormattedContent(): String { + val body = content ?: return "" + return if (looksLikeJson(body)) FormatUtils.formatJson(body) else body + } + + private fun looksLikeJson(body: String): Boolean { + val trimmed = body.trim() + return trimmed.startsWith("{") || trimmed.startsWith("[") + } + + private companion object { + private const val PREVIEW_MAX_LENGTH = 120 + } +} diff --git a/library/src/main/kotlin/com/chuckerteam/chucker/internal/data/entity/WebSocketTrafficTuple.kt b/library/src/main/kotlin/com/chuckerteam/chucker/internal/data/entity/WebSocketTrafficTuple.kt new file mode 100644 index 000000000..57b594df0 --- /dev/null +++ b/library/src/main/kotlin/com/chuckerteam/chucker/internal/data/entity/WebSocketTrafficTuple.kt @@ -0,0 +1,65 @@ +package com.chuckerteam.chucker.internal.data.entity + +import androidx.room.ColumnInfo +import com.chuckerteam.chucker.internal.support.FormatUtils + +/** + * A subset of [WebSocketTraffic] to perform faster read operations on the repository. + * The [content] column is truncated at the query level so the list stays lightweight. + */ +@Suppress("LongParameterList") +internal data class WebSocketTrafficTuple( + @ColumnInfo(name = "id") var id: Long, + @ColumnInfo(name = "timestamp") var timestamp: Long, + @ColumnInfo(name = "sequence") var sequence: Long, + @ColumnInfo(name = "connectionId") var connectionId: Long, + @ColumnInfo(name = "type") var type: String, + @ColumnInfo(name = "host") var host: String?, + @ColumnInfo(name = "path") var path: String?, + @ColumnInfo(name = "scheme") var scheme: String?, + @ColumnInfo(name = "content") var content: String?, + @ColumnInfo(name = "contentType") var contentType: String?, + @ColumnInfo(name = "size") var size: Long?, + @ColumnInfo(name = "code") var code: Int?, + @ColumnInfo(name = "error") var error: String?, +) { + val typeEnum: WebSocketTraffic.Type + get() = runCatching { WebSocketTraffic.Type.valueOf(type) }.getOrDefault(WebSocketTraffic.Type.FAILURE) + + val direction: WebSocketTraffic.Direction + get() = + when (typeEnum) { + WebSocketTraffic.Type.SEND -> WebSocketTraffic.Direction.OUTGOING + WebSocketTraffic.Type.RECEIVE -> WebSocketTraffic.Direction.INCOMING + else -> WebSocketTraffic.Direction.NONE + } + + val isSsl: Boolean + get() = scheme.equals("wss", ignoreCase = true) + + val sizeString: String? + get() = size?.let { FormatUtils.formatByteCount(it, true) } + + val typeLabel: String + get() = + when (typeEnum) { + WebSocketTraffic.Type.OPEN -> "OPEN" + WebSocketTraffic.Type.SEND -> "SENT" + WebSocketTraffic.Type.RECEIVE -> "RECEIVED" + WebSocketTraffic.Type.CLOSING -> "CLOSING" + WebSocketTraffic.Type.CLOSED -> "CLOSED" + WebSocketTraffic.Type.FAILURE -> "FAILURE" + } + + /** + * One line summary rendered under the type in the list. + */ + fun summary(): String = + when (typeEnum) { + WebSocketTraffic.Type.OPEN -> path.orEmpty() + WebSocketTraffic.Type.SEND, WebSocketTraffic.Type.RECEIVE -> content?.replace("\n", " ")?.trim().orEmpty() + WebSocketTraffic.Type.CLOSING, WebSocketTraffic.Type.CLOSED -> + listOfNotNull(code?.toString(), content).joinToString(" ") + WebSocketTraffic.Type.FAILURE -> error.orEmpty() + } +} diff --git a/library/src/main/kotlin/com/chuckerteam/chucker/internal/data/repository/RepositoryProvider.kt b/library/src/main/kotlin/com/chuckerteam/chucker/internal/data/repository/RepositoryProvider.kt index 8a0f6f721..8fc46a8ce 100644 --- a/library/src/main/kotlin/com/chuckerteam/chucker/internal/data/repository/RepositoryProvider.kt +++ b/library/src/main/kotlin/com/chuckerteam/chucker/internal/data/repository/RepositoryProvider.kt @@ -11,19 +11,26 @@ import com.chuckerteam.chucker.internal.data.room.ChuckerDatabase */ internal object RepositoryProvider { private var transactionRepository: HttpTransactionRepository? = null + private var webSocketTrafficRepository: WebSocketTrafficRepository? = null fun transaction(): HttpTransactionRepository = checkNotNull(transactionRepository) { "You can't access the transaction repository if you don't initialize it!" } + fun webSocket(): WebSocketTrafficRepository = + checkNotNull(webSocketTrafficRepository) { + "You can't access the websocket repository if you don't initialize it!" + } + /** * Idempotent method. Must be called before accessing the repositories. */ fun initialize(applicationContext: Context) { - if (transactionRepository == null) { + if (transactionRepository == null || webSocketTrafficRepository == null) { val db = ChuckerDatabase.create(applicationContext) transactionRepository = HttpTransactionDatabaseRepository(db) + webSocketTrafficRepository = WebSocketTrafficDatabaseRepository(db) } } @@ -33,5 +40,6 @@ internal object RepositoryProvider { @VisibleForTesting fun close() { transactionRepository = null + webSocketTrafficRepository = null } } diff --git a/library/src/main/kotlin/com/chuckerteam/chucker/internal/data/repository/WebSocketTrafficDatabaseRepository.kt b/library/src/main/kotlin/com/chuckerteam/chucker/internal/data/repository/WebSocketTrafficDatabaseRepository.kt new file mode 100644 index 000000000..fc3414125 --- /dev/null +++ b/library/src/main/kotlin/com/chuckerteam/chucker/internal/data/repository/WebSocketTrafficDatabaseRepository.kt @@ -0,0 +1,35 @@ +package com.chuckerteam.chucker.internal.data.repository + +import androidx.lifecycle.LiveData +import com.chuckerteam.chucker.internal.data.entity.WebSocketTraffic +import com.chuckerteam.chucker.internal.data.entity.WebSocketTrafficTuple +import com.chuckerteam.chucker.internal.data.room.ChuckerDatabase + +internal class WebSocketTrafficDatabaseRepository( + private val database: ChuckerDatabase, +) : WebSocketTrafficRepository { + private val webSocketDao get() = database.webSocketTrafficDao() + + override suspend fun insertWebSocketTraffic(traffic: WebSocketTraffic) { + val id = webSocketDao.insert(traffic) + traffic.id = id ?: 0 + } + + override fun getSortedWebSocketTrafficTuples(): LiveData> = + webSocketDao.getSortedTuples() + + override fun getWebSocketTraffic(id: Long): LiveData = webSocketDao.getById(id) + + override suspend fun getAllWebSocketTraffic(): List = webSocketDao.getAll() + + override fun getWebSocketTrafficInTimeRange(minTimestamp: Long?): List = + webSocketDao.getInTimeRange(minTimestamp ?: 0L) + + override suspend fun deleteAllWebSocketTraffic() { + webSocketDao.deleteAll() + } + + override suspend fun deleteOldWebSocketTraffic(threshold: Long) { + webSocketDao.deleteBefore(threshold) + } +} diff --git a/library/src/main/kotlin/com/chuckerteam/chucker/internal/data/repository/WebSocketTrafficRepository.kt b/library/src/main/kotlin/com/chuckerteam/chucker/internal/data/repository/WebSocketTrafficRepository.kt new file mode 100644 index 000000000..8d9618182 --- /dev/null +++ b/library/src/main/kotlin/com/chuckerteam/chucker/internal/data/repository/WebSocketTrafficRepository.kt @@ -0,0 +1,25 @@ +package com.chuckerteam.chucker.internal.data.repository + +import androidx.lifecycle.LiveData +import com.chuckerteam.chucker.internal.data.entity.WebSocketTraffic +import com.chuckerteam.chucker.internal.data.entity.WebSocketTrafficTuple + +/** + * Repository interface for all operations Chucker needs to work with [WebSocketTraffic] and + * [WebSocketTrafficTuple]. See [WebSocketTrafficDatabaseRepository] for the Room backed impl. + */ +internal interface WebSocketTrafficRepository { + suspend fun insertWebSocketTraffic(traffic: WebSocketTraffic) + + fun getSortedWebSocketTrafficTuples(): LiveData> + + fun getWebSocketTraffic(id: Long): LiveData + + suspend fun getAllWebSocketTraffic(): List + + fun getWebSocketTrafficInTimeRange(minTimestamp: Long?): List + + suspend fun deleteAllWebSocketTraffic() + + suspend fun deleteOldWebSocketTraffic(threshold: Long) +} diff --git a/library/src/main/kotlin/com/chuckerteam/chucker/internal/data/room/ChuckerDatabase.kt b/library/src/main/kotlin/com/chuckerteam/chucker/internal/data/room/ChuckerDatabase.kt index 653231229..d4929fd68 100644 --- a/library/src/main/kotlin/com/chuckerteam/chucker/internal/data/room/ChuckerDatabase.kt +++ b/library/src/main/kotlin/com/chuckerteam/chucker/internal/data/room/ChuckerDatabase.kt @@ -5,11 +5,14 @@ import androidx.room.Database import androidx.room.Room import androidx.room.RoomDatabase import com.chuckerteam.chucker.internal.data.entity.HttpTransaction +import com.chuckerteam.chucker.internal.data.entity.WebSocketTraffic -@Database(entities = [HttpTransaction::class], version = 10, exportSchema = false) +@Database(entities = [HttpTransaction::class, WebSocketTraffic::class], version = 12, exportSchema = false) internal abstract class ChuckerDatabase : RoomDatabase() { abstract fun transactionDao(): HttpTransactionDao + abstract fun webSocketTrafficDao(): WebSocketTrafficDao + companion object { private const val DB_NAME = "chucker.db" diff --git a/library/src/main/kotlin/com/chuckerteam/chucker/internal/data/room/WebSocketTrafficDao.kt b/library/src/main/kotlin/com/chuckerteam/chucker/internal/data/room/WebSocketTrafficDao.kt new file mode 100644 index 000000000..9d7d795a3 --- /dev/null +++ b/library/src/main/kotlin/com/chuckerteam/chucker/internal/data/room/WebSocketTrafficDao.kt @@ -0,0 +1,36 @@ +package com.chuckerteam.chucker.internal.data.room + +import androidx.lifecycle.LiveData +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.Query +import com.chuckerteam.chucker.internal.data.entity.WebSocketTraffic +import com.chuckerteam.chucker.internal.data.entity.WebSocketTrafficTuple + +@Dao +internal interface WebSocketTrafficDao { + @Query( + "SELECT id, timestamp, sequence, connectionId, type, host, path, scheme, " + + "substr(content, 1, 200) AS content, contentType, size, code, error FROM " + + "websocket_traffic ORDER BY timestamp DESC, sequence DESC", + ) + fun getSortedTuples(): LiveData> + + @Insert + suspend fun insert(traffic: WebSocketTraffic): Long? + + @Query("DELETE FROM websocket_traffic") + suspend fun deleteAll(): Int + + @Query("SELECT * FROM websocket_traffic WHERE id = :id") + fun getById(id: Long): LiveData + + @Query("DELETE FROM websocket_traffic WHERE timestamp <= :threshold") + suspend fun deleteBefore(threshold: Long): Int + + @Query("SELECT * FROM websocket_traffic") + suspend fun getAll(): List + + @Query("SELECT * FROM websocket_traffic WHERE timestamp >= :timestamp ORDER BY timestamp DESC, sequence DESC") + fun getInTimeRange(timestamp: Long): List +} diff --git a/library/src/main/kotlin/com/chuckerteam/chucker/internal/support/NotificationHelper.kt b/library/src/main/kotlin/com/chuckerteam/chucker/internal/support/NotificationHelper.kt index 89e22c0fd..3c88a86f8 100644 --- a/library/src/main/kotlin/com/chuckerteam/chucker/internal/support/NotificationHelper.kt +++ b/library/src/main/kotlin/com/chuckerteam/chucker/internal/support/NotificationHelper.kt @@ -13,6 +13,7 @@ import androidx.core.util.size import com.chuckerteam.chucker.R import com.chuckerteam.chucker.api.Chucker import com.chuckerteam.chucker.internal.data.entity.HttpTransaction +import com.chuckerteam.chucker.internal.data.entity.WebSocketTraffic import com.chuckerteam.chucker.internal.ui.BaseChuckerActivity internal class NotificationHelper( @@ -27,12 +28,18 @@ internal class NotificationHelper( private const val INTENT_REQUEST_CODE = 11 private val transactionBuffer = LongSparseArray() private val transactionIdsSet = HashSet() + private val webSocketBuffer = LongSparseArray() + private val webSocketIdsSet = HashSet() fun clearBuffer() { synchronized(transactionBuffer) { transactionBuffer.clear() transactionIdsSet.clear() } + synchronized(webSocketBuffer) { + webSocketBuffer.clear() + webSocketIdsSet.clear() + } } } @@ -92,6 +99,19 @@ internal class NotificationHelper( } } + private fun addToWebSocketBuffer(traffic: WebSocketTraffic) { + if (traffic.id == 0L) { + return + } + synchronized(webSocketBuffer) { + webSocketIdsSet.add(traffic.id) + webSocketBuffer.put(traffic.id, traffic) + if (webSocketBuffer.size > BUFFER_SIZE) { + webSocketBuffer.removeAt(0) + } + } + } + private fun canShowNotifications(): Boolean = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { notificationManager.areNotificationsEnabled() @@ -101,39 +121,67 @@ internal class NotificationHelper( fun show(transaction: HttpTransaction) { addToBuffer(transaction) - if (!BaseChuckerActivity.isInForeground && canShowNotifications()) { - val builder = - NotificationCompat - .Builder(context, TRANSACTIONS_CHANNEL_ID) - .setContentIntent(transactionsScreenIntent) - .setLocalOnly(true) - .setSmallIcon(R.drawable.chucker_ic_transaction_notification) - .setColor(ContextCompat.getColor(context, R.color.chucker_color_primary)) - .setContentTitle(context.getString(R.string.chucker_http_notification_title)) - .setAutoCancel(true) - .addAction(clearAction) - val inboxStyle = NotificationCompat.InboxStyle() - synchronized(transactionBuffer) { - var count = 0 - for (i in transactionBuffer.size - 1 downTo 0) { - val bufferedTransaction = transactionBuffer.valueAt(i) - if ((bufferedTransaction != null) && count < BUFFER_SIZE) { - if (count == 0) { - builder.setContentText(bufferedTransaction.notificationText) - } - inboxStyle.addLine(bufferedTransaction.notificationText) - } - count++ - } - builder.setStyle(inboxStyle) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - builder.setSubText(transactionIdsSet.size.toString()) - } else { - builder.setNumber(transactionIdsSet.size) - } + updateNotification() + } + + fun show(traffic: WebSocketTraffic) { + addToWebSocketBuffer(traffic) + updateNotification() + } + + private fun updateNotification() { + if (BaseChuckerActivity.isInForeground || !canShowNotifications()) { + return + } + val builder = + NotificationCompat + .Builder(context, TRANSACTIONS_CHANNEL_ID) + .setContentIntent(transactionsScreenIntent) + .setLocalOnly(true) + .setSmallIcon(R.drawable.chucker_ic_transaction_notification) + .setColor(ContextCompat.getColor(context, R.color.chucker_color_primary)) + .setContentTitle(context.getString(R.string.chucker_http_notification_title)) + .setAutoCancel(true) + .addAction(clearAction) + val inboxStyle = NotificationCompat.InboxStyle() + val lines = collectNotificationLines() + lines.firstOrNull()?.let { builder.setContentText(it) } + lines.forEach { inboxStyle.addLine(it) } + builder.setStyle(inboxStyle) + + val total = + synchronized(transactionBuffer) { transactionIdsSet.size } + + synchronized(webSocketBuffer) { webSocketIdsSet.size } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + builder.setSubText(total.toString()) + } else { + builder.setNumber(total) + } + notificationManager.notify(TRANSACTION_NOTIFICATION_ID, builder.build()) + } + + /** + * Merges the most recent HTTP and WebSocket events into a single list of notification lines, + * newest first, capped at [BUFFER_SIZE]. + */ + private fun collectNotificationLines(): List { + val entries = mutableListOf>() + synchronized(transactionBuffer) { + for (i in 0 until transactionBuffer.size) { + val transaction = transactionBuffer.valueAt(i) ?: continue + entries.add((transaction.requestDate ?: 0L) to transaction.notificationText) + } + } + synchronized(webSocketBuffer) { + for (i in 0 until webSocketBuffer.size) { + val traffic = webSocketBuffer.valueAt(i) ?: continue + entries.add(traffic.timestamp to traffic.notificationText) } - notificationManager.notify(TRANSACTION_NOTIFICATION_ID, builder.build()) } + return entries + .sortedByDescending { it.first } + .take(BUFFER_SIZE) + .map { it.second } } fun dismissNotifications() { diff --git a/library/src/main/kotlin/com/chuckerteam/chucker/internal/support/RecordingWebSocket.kt b/library/src/main/kotlin/com/chuckerteam/chucker/internal/support/RecordingWebSocket.kt new file mode 100644 index 000000000..4f4194c4b --- /dev/null +++ b/library/src/main/kotlin/com/chuckerteam/chucker/internal/support/RecordingWebSocket.kt @@ -0,0 +1,35 @@ +package com.chuckerteam.chucker.internal.support + +import okhttp3.Request +import okhttp3.WebSocket +import okio.ByteString + +/** + * An [okhttp3.WebSocket] wrapper that records outgoing frames into Chucker (via [recorder]) + * before delegating to the real socket. Every other operation is a transparent passthrough. + */ +internal class RecordingWebSocket( + private val delegate: WebSocket, + private val recorder: WebSocketTrafficRecorder, +) : WebSocket { + override fun request(): Request = delegate.request() + + override fun queueSize(): Long = delegate.queueSize() + + override fun send(text: String): Boolean { + recorder.onSendText(text) + return delegate.send(text) + } + + override fun send(bytes: ByteString): Boolean { + recorder.onSendBytes(bytes) + return delegate.send(bytes) + } + + override fun close( + code: Int, + reason: String?, + ): Boolean = delegate.close(code, reason) + + override fun cancel() = delegate.cancel() +} diff --git a/library/src/main/kotlin/com/chuckerteam/chucker/internal/support/TrafficListSharable.kt b/library/src/main/kotlin/com/chuckerteam/chucker/internal/support/TrafficListSharable.kt new file mode 100644 index 000000000..cd423b896 --- /dev/null +++ b/library/src/main/kotlin/com/chuckerteam/chucker/internal/support/TrafficListSharable.kt @@ -0,0 +1,42 @@ +package com.chuckerteam.chucker.internal.support + +import android.content.Context +import com.chuckerteam.chucker.R.string +import com.chuckerteam.chucker.internal.data.entity.HttpTransaction +import com.chuckerteam.chucker.internal.data.entity.WebSocketTraffic +import okio.Buffer +import okio.Source + +/** + * Renders a mixed list of HTTP transactions and WebSocket events as a single shareable text, + * ordered chronologically (oldest first) so it reads like a timeline. + */ +internal class TrafficListSharable( + httpTransactions: List, + webSocketTraffic: List, +) : Sharable { + private class Entry( + val timestamp: Long, + val secondaryOrder: Long, + val sharable: Sharable, + ) + + private val entries: List = + httpTransactions.map { + Entry(it.requestDate ?: 0L, it.id, TransactionDetailsSharable(it, encodeUrls = false)) + } + + webSocketTraffic.map { + Entry(it.timestamp, it.sequence, WebSocketTrafficSharable(it)) + } + + override fun toSharableContent(context: Context): Source = + Buffer().writeUtf8( + entries + .sortedWith(compareBy({ it.timestamp }, { it.secondaryOrder })) + .joinToString( + separator = "\n${context.getString(string.chucker_export_separator)}\n", + prefix = "${context.getString(string.chucker_export_prefix)}\n", + postfix = "\n${context.getString(string.chucker_export_postfix)}\n", + ) { it.sharable.toSharableUtf8Content(context) }, + ) +} diff --git a/library/src/main/kotlin/com/chuckerteam/chucker/internal/support/WebSocketTrafficRecorder.kt b/library/src/main/kotlin/com/chuckerteam/chucker/internal/support/WebSocketTrafficRecorder.kt new file mode 100644 index 000000000..2abce95ad --- /dev/null +++ b/library/src/main/kotlin/com/chuckerteam/chucker/internal/support/WebSocketTrafficRecorder.kt @@ -0,0 +1,143 @@ +package com.chuckerteam.chucker.internal.support + +import com.chuckerteam.chucker.api.ChuckerCollector +import com.chuckerteam.chucker.internal.data.entity.WebSocketTraffic +import okhttp3.Request +import okhttp3.Response +import okio.ByteString +import java.util.concurrent.atomic.AtomicLong + +/** + * Shared helper that turns raw WebSocket events (from the listener decorator and the recording + * [okhttp3.WebSocket] wrapper) into [WebSocketTraffic] rows and hands them to the [ChuckerCollector]. + * + * A single recorder instance is shared for the lifetime of one WebSocket connection so that every + * event carries the same [connectionId]. + */ +@Suppress("TooManyFunctions") +internal class WebSocketTrafficRecorder( + private val request: Request, + private val connectionId: Long, + private val collector: ChuckerCollector, + private val maxContentLength: Long, +) { + private val scheme: String = if (request.url.isHttps) "wss" else "ws" + private val host: String = request.url.host + private val path: String = + request.url.encodedPath + (request.url.encodedQuery?.let { "?$it" } ?: "") + + fun onOpen(response: Response) { + val headers = response.headers.joinToString("\n") { (name, value) -> "$name: $value" } + record( + type = WebSocketTraffic.Type.OPEN, + content = "HTTP ${response.code} ${response.message}\n$headers", + contentType = null, + size = null, + ) + } + + fun onSendText(text: String) = recordText(WebSocketTraffic.Type.SEND, text) + + fun onSendBytes(bytes: ByteString) = recordBytes(WebSocketTraffic.Type.SEND, bytes) + + fun onReceiveText(text: String) = recordText(WebSocketTraffic.Type.RECEIVE, text) + + fun onReceiveBytes(bytes: ByteString) = recordBytes(WebSocketTraffic.Type.RECEIVE, bytes) + + fun onClosing( + code: Int, + reason: String, + ) = recordClose(WebSocketTraffic.Type.CLOSING, code, reason) + + fun onClosed( + code: Int, + reason: String, + ) = recordClose(WebSocketTraffic.Type.CLOSED, code, reason) + + fun onFailure( + throwable: Throwable, + response: Response?, + ) { + val responseInfo = response?.let { "\nHTTP ${it.code} ${it.message}" }.orEmpty() + record( + type = WebSocketTraffic.Type.FAILURE, + content = (throwable.message ?: throwable.toString()) + responseInfo, + contentType = null, + size = null, + error = throwable.message ?: throwable.toString(), + ) + } + + private fun recordText( + type: WebSocketTraffic.Type, + text: String, + ) = record( + type = type, + content = truncate(text), + contentType = CONTENT_TYPE_TEXT, + size = text.toByteArray(Charsets.UTF_8).size.toLong(), + ) + + private fun recordBytes( + type: WebSocketTraffic.Type, + bytes: ByteString, + ) = record( + type = type, + content = "[binary] " + truncate(bytes.hex()), + contentType = CONTENT_TYPE_BINARY, + size = bytes.size.toLong(), + ) + + private fun recordClose( + type: WebSocketTraffic.Type, + code: Int, + reason: String, + ) = record( + type = type, + content = reason, + contentType = null, + size = null, + code = code, + ) + + @Suppress("LongParameterList") + private fun record( + type: WebSocketTraffic.Type, + content: String?, + contentType: String?, + size: Long?, + code: Int? = null, + error: String? = null, + ) { + collector.onWebSocketTraffic( + WebSocketTraffic( + timestamp = System.currentTimeMillis(), + sequence = sequenceGenerator.incrementAndGet(), + connectionId = connectionId, + type = type.name, + url = "$scheme://$host$path", + host = host, + path = path, + scheme = scheme, + content = content, + contentType = contentType, + size = size, + code = code, + error = error, + ), + ) + } + + private fun truncate(value: String): String { + val limit = maxContentLength.coerceAtMost(Int.MAX_VALUE.toLong()).toInt() + return if (value.length > limit) value.substring(0, limit) + "…" else value + } + + private companion object { + private const val CONTENT_TYPE_TEXT = "TEXT" + private const val CONTENT_TYPE_BINARY = "BINARY" + + // Process-wide monotonic counter; guarantees a stable order for events sharing a timestamp. + private val sequenceGenerator = AtomicLong(0) + } +} diff --git a/library/src/main/kotlin/com/chuckerteam/chucker/internal/support/WebSocketTrafficSharable.kt b/library/src/main/kotlin/com/chuckerteam/chucker/internal/support/WebSocketTrafficSharable.kt new file mode 100644 index 000000000..d9f7c807c --- /dev/null +++ b/library/src/main/kotlin/com/chuckerteam/chucker/internal/support/WebSocketTrafficSharable.kt @@ -0,0 +1,32 @@ +package com.chuckerteam.chucker.internal.support + +import android.content.Context +import com.chuckerteam.chucker.internal.data.entity.WebSocketTraffic +import okio.Buffer +import okio.Source + +/** + * Renders a single [WebSocketTraffic] event as shareable plain text, mirroring the layout of + * [TransactionDetailsSharable] for HTTP transactions. + */ +internal class WebSocketTrafficSharable( + private val traffic: WebSocketTraffic, +) : Sharable { + override fun toSharableContent(context: Context): Source = + Buffer().apply { + writeUtf8("Type: WebSocket ${traffic.typeLabel}\n") + when (traffic.direction) { + WebSocketTraffic.Direction.OUTGOING -> writeUtf8("Direction: ↑ Outgoing\n") + WebSocketTraffic.Direction.INCOMING -> writeUtf8("Direction: ↓ Incoming\n") + WebSocketTraffic.Direction.NONE -> Unit + } + writeUtf8("URL: ${traffic.url}\n") + writeUtf8("Time: ${traffic.timeString}\n") + traffic.code?.let { writeUtf8("Code: $it\n") } + traffic.error?.let { writeUtf8("Error: $it\n") } + traffic.sizeString?.let { writeUtf8("Size: $it\n") } + writeUtf8("\n") + val body = traffic.getFormattedContent() + writeUtf8(body.ifBlank { "(empty)" }) + } +} diff --git a/library/src/main/kotlin/com/chuckerteam/chucker/internal/ui/MainActivity.kt b/library/src/main/kotlin/com/chuckerteam/chucker/internal/ui/MainActivity.kt index e38201ce0..e6f17758c 100644 --- a/library/src/main/kotlin/com/chuckerteam/chucker/internal/ui/MainActivity.kt +++ b/library/src/main/kotlin/com/chuckerteam/chucker/internal/ui/MainActivity.kt @@ -29,19 +29,22 @@ import com.chuckerteam.chucker.R import com.chuckerteam.chucker.api.Chucker import com.chuckerteam.chucker.databinding.ChuckerActivityMainBinding import com.chuckerteam.chucker.internal.data.entity.HttpTransaction +import com.chuckerteam.chucker.internal.data.entity.WebSocketTraffic import com.chuckerteam.chucker.internal.data.model.DialogData import com.chuckerteam.chucker.internal.support.FileSaver import com.chuckerteam.chucker.internal.support.HarUtils import com.chuckerteam.chucker.internal.support.Logger import com.chuckerteam.chucker.internal.support.Sharable +import com.chuckerteam.chucker.internal.support.TrafficListSharable import com.chuckerteam.chucker.internal.support.TransactionDetailsHarSharable -import com.chuckerteam.chucker.internal.support.TransactionListDetailsSharable import com.chuckerteam.chucker.internal.support.shareAsFile import com.chuckerteam.chucker.internal.support.showDialog import com.chuckerteam.chucker.internal.ui.MainActivity.ExportType.HAR import com.chuckerteam.chucker.internal.ui.MainActivity.ExportType.TEXT +import com.chuckerteam.chucker.internal.ui.model.TrafficFilter import com.chuckerteam.chucker.internal.ui.transaction.TransactionActivity import com.chuckerteam.chucker.internal.ui.transaction.TransactionAdapter +import com.chuckerteam.chucker.internal.ui.websocket.WebSocketTrafficActivity import com.google.android.material.snackbar.Snackbar import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -50,6 +53,7 @@ import okio.Source import okio.buffer import okio.source +@Suppress("TooManyFunctions") internal class MainActivity : BaseChuckerActivity(), SearchView.OnQueryTextListener { @@ -101,6 +105,9 @@ internal class MainActivity : onTransactionLongClick = { transactionId -> viewModel.startSelection(transactionId) }, + onWebSocketClick = { trafficId -> + WebSocketTrafficActivity.start(this, trafficId) + }, ) with(mainBinding) { @@ -120,13 +127,14 @@ internal class MainActivity : ) adapter = transactionsAdapter } + setUpFilterChips() } - viewModel.transactions.observe( + viewModel.listItems.observe( this, - ) { transactionTuples -> - transactionsAdapter.submitList(transactionTuples) - mainBinding.tutorialGroup.isVisible = transactionTuples.isEmpty() + ) { rows -> + transactionsAdapter.submitList(rows) + mainBinding.tutorialGroup.isVisible = rows.isEmpty() } if (Chucker.showNotifications && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { @@ -198,6 +206,20 @@ internal class MainActivity : searchView.setIconifiedByDefault(true) } + private fun setUpFilterChips() { + mainBinding.filterChips.setOnCheckedStateChangeListener { _, checkedIds -> + val filter = + when (checkedIds.firstOrNull()) { + R.id.chipHttp -> TrafficFilter.HTTP + R.id.chipWsSent -> TrafficFilter.WS_SENT + R.id.chipWsReceived -> TrafficFilter.WS_RECEIVED + R.id.chipWsStatus -> TrafficFilter.WS_STATUS + else -> TrafficFilter.ALL + } + viewModel.setTypeFilter(filter) + } + } + @Suppress("LongMethod") override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) { @@ -223,8 +245,11 @@ internal class MainActivity : showDialog( getExportDialogData(stringId), onPositiveClick = { - exportTransactions(EXPORT_TXT_FILE_NAME) { transactions -> - TransactionListDetailsSharable(transactions, encodeUrls = false) + exportTransactions( + EXPORT_TXT_FILE_NAME, + includeWebSocket = true, + ) { transactions, webSocketTraffic -> + TrafficListSharable(transactions, webSocketTraffic) } }, onNegativeClick = null, @@ -242,7 +267,7 @@ internal class MainActivity : showDialog( getExportDialogData(stringId), onPositiveClick = { - exportTransactions(EXPORT_HAR_FILE_NAME) { transactions -> + exportTransactions(EXPORT_HAR_FILE_NAME, includeWebSocket = false) { transactions, _ -> TransactionDetailsHarSharable( HarUtils.harStringFromTransactions( transactions, @@ -292,19 +317,29 @@ internal class MainActivity : transactionsAdapter.setSelectedTransactionIds(selectedIds) } + private suspend fun webSocketTrafficForExport(): List = + if (viewModel.isItemSelected.value == true) { + // Selection mode is HTTP-only; exclude WebSocket events when a selection is active. + emptyList() + } else { + viewModel.getWebSocketTraffic() + } + private fun exportTransactions( fileName: String, - block: suspend (List) -> Sharable, + includeWebSocket: Boolean, + block: suspend (List, List) -> Sharable, ) { val applicationContext = this.applicationContext lifecycleScope.launch { val transactions = viewModel.getTransactions() - if (transactions.isEmpty()) { + val webSocketTraffic = if (includeWebSocket) webSocketTrafficForExport() else emptyList() + if (transactions.isEmpty() && webSocketTraffic.isEmpty()) { showToast(applicationContext.getString(R.string.chucker_export_empty_text)) return@launch } - val sharableTransactions = block(transactions) + val sharableTransactions = block(transactions, webSocketTraffic) val shareIntent = withContext(Dispatchers.IO) { sharableTransactions.shareAsFile( @@ -407,16 +442,17 @@ internal class MainActivity : private suspend fun prepareDataToSave(exportType: ExportType): Source? { val transactions = viewModel.getTransactions() - if (transactions.isEmpty()) { + val webSocketTraffic = if (exportType == TEXT) webSocketTrafficForExport() else emptyList() + if (transactions.isEmpty() && webSocketTraffic.isEmpty()) { showToast(applicationContext.getString(R.string.chucker_save_empty_text)) return null } return withContext(Dispatchers.IO) { when (exportType) { TEXT -> { - TransactionListDetailsSharable( + TrafficListSharable( transactions, - encodeUrls = false, + webSocketTraffic, ).toSharableContent(this@MainActivity) } diff --git a/library/src/main/kotlin/com/chuckerteam/chucker/internal/ui/MainViewModel.kt b/library/src/main/kotlin/com/chuckerteam/chucker/internal/ui/MainViewModel.kt index 484a31153..ac2c48309 100644 --- a/library/src/main/kotlin/com/chuckerteam/chucker/internal/ui/MainViewModel.kt +++ b/library/src/main/kotlin/com/chuckerteam/chucker/internal/ui/MainViewModel.kt @@ -2,6 +2,7 @@ package com.chuckerteam.chucker.internal.ui import androidx.core.text.isDigitsOnly import androidx.lifecycle.LiveData +import androidx.lifecycle.MediatorLiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.distinctUntilChanged @@ -9,8 +10,12 @@ import androidx.lifecycle.switchMap import androidx.lifecycle.viewModelScope import com.chuckerteam.chucker.internal.data.entity.HttpTransaction import com.chuckerteam.chucker.internal.data.entity.HttpTransactionTuple +import com.chuckerteam.chucker.internal.data.entity.WebSocketTraffic +import com.chuckerteam.chucker.internal.data.entity.WebSocketTrafficTuple import com.chuckerteam.chucker.internal.data.repository.RepositoryProvider import com.chuckerteam.chucker.internal.support.NotificationHelper +import com.chuckerteam.chucker.internal.ui.model.TrafficFilter +import com.chuckerteam.chucker.internal.ui.model.TransactionRow import kotlinx.coroutines.launch internal class MainViewModel : ViewModel() { @@ -19,6 +24,11 @@ internal class MainViewModel : ViewModel() { */ private val currentFilter = MutableLiveData("") + /** + * Holds the current traffic type filter selected via the filter chips. + */ + private val currentTypeFilter = MutableLiveData(TrafficFilter.ALL) + /** * Holds the list of selected transaction IDs. */ @@ -29,7 +39,7 @@ internal class MainViewModel : ViewModel() { */ private val mutableIsItemSelected = MutableLiveData(false) - internal val transactions: LiveData> = + private val httpTuples: LiveData> = currentFilter.switchMap { searchQuery -> with(RepositoryProvider.transaction()) { when { @@ -40,6 +50,63 @@ internal class MainViewModel : ViewModel() { } } + private val webSocketTuples: LiveData> = + RepositoryProvider.webSocket().getSortedWebSocketTrafficTuples() + + /** + * The unified, chronologically sorted list of HTTP transactions and WebSocket events, + * respecting both the search query and the selected traffic type filter. + */ + internal val listItems: LiveData> = + MediatorLiveData>().apply { + addSource(httpTuples) { value = buildRows() } + addSource(webSocketTuples) { value = buildRows() } + addSource(currentFilter) { value = buildRows() } + addSource(currentTypeFilter) { value = buildRows() } + } + + private fun buildRows(): List { + val typeFilter = currentTypeFilter.value ?: TrafficFilter.ALL + val search = currentFilter.value.orEmpty() + + val rows = mutableListOf() + if (typeFilter == TrafficFilter.ALL || typeFilter == TrafficFilter.HTTP) { + // httpTuples are already search-filtered at the SQL level. + httpTuples.value.orEmpty().forEach { rows.add(TransactionRow.Http(it)) } + } + webSocketTuples.value + .orEmpty() + .filter { webSocketMatchesFilter(it, typeFilter) && webSocketMatchesSearch(it, search) } + .forEach { rows.add(TransactionRow.WebSocket(it)) } + + return rows.sortedWith( + compareByDescending { it.timestamp } + .thenByDescending { it.secondaryOrder }, + ) + } + + private fun webSocketMatchesFilter( + tuple: WebSocketTrafficTuple, + filter: TrafficFilter, + ): Boolean = + when (filter) { + TrafficFilter.ALL -> true + TrafficFilter.HTTP -> false + TrafficFilter.WS_SENT -> tuple.direction == WebSocketTraffic.Direction.OUTGOING + TrafficFilter.WS_RECEIVED -> tuple.direction == WebSocketTraffic.Direction.INCOMING + TrafficFilter.WS_STATUS -> tuple.direction == WebSocketTraffic.Direction.NONE + } + + private fun webSocketMatchesSearch( + tuple: WebSocketTrafficTuple, + search: String, + ): Boolean { + if (search.isBlank()) return true + val query = search.lowercase() + return listOfNotNull(tuple.path, tuple.host, tuple.content, tuple.typeLabel) + .any { it.lowercase().contains(query) } + } + /** * LiveData indicating whether any items are currently selected. * Observers are notified only when the value changes. @@ -59,6 +126,12 @@ internal class MainViewModel : ViewModel() { } } + /** + * Returns all captured WebSocket events, used for text/log export. + */ + suspend fun getWebSocketTraffic(): List = + RepositoryProvider.webSocket().getAllWebSocketTraffic() + /** * Toggles the selection state of the given transaction ID. * If the ID is already selected, it will be removed from the selection. @@ -107,6 +180,15 @@ internal class MainViewModel : ViewModel() { currentFilter.value = searchQuery } + /** + * Updates the traffic type filter to trigger filtering of the unified list. + * + * @param filter The new traffic type filter selected via the chips. + */ + internal fun setTypeFilter(filter: TrafficFilter) { + currentTypeFilter.value = filter + } + /** * Returns the list of currently selected transaction IDs. * diff --git a/library/src/main/kotlin/com/chuckerteam/chucker/internal/ui/model/TransactionRow.kt b/library/src/main/kotlin/com/chuckerteam/chucker/internal/ui/model/TransactionRow.kt new file mode 100644 index 000000000..9c58aa01d --- /dev/null +++ b/library/src/main/kotlin/com/chuckerteam/chucker/internal/ui/model/TransactionRow.kt @@ -0,0 +1,57 @@ +package com.chuckerteam.chucker.internal.ui.model + +import androidx.recyclerview.widget.DiffUtil +import com.chuckerteam.chucker.internal.data.entity.HttpTransactionTuple +import com.chuckerteam.chucker.internal.data.entity.WebSocketTrafficTuple + +/** + * A single row in the unified traffic list. HTTP transactions and WebSocket events are mixed into + * one chronological list, so the adapter renders each variant with its own view type. + */ +internal sealed interface TransactionRow { + /** Identifier unique within the row's own type (HTTP ids and WS ids live in separate tables). */ + val rowId: Long + val timestamp: Long + + /** Tiebreaker for events that share a [timestamp]; higher means more recent. */ + val secondaryOrder: Long + + data class Http( + val tuple: HttpTransactionTuple, + ) : TransactionRow { + override val rowId: Long get() = tuple.id + override val timestamp: Long get() = tuple.requestDate ?: 0L + override val secondaryOrder: Long get() = tuple.id + } + + data class WebSocket( + val tuple: WebSocketTrafficTuple, + ) : TransactionRow { + override val rowId: Long get() = tuple.id + override val timestamp: Long get() = tuple.timestamp + override val secondaryOrder: Long get() = tuple.sequence + } +} + +/** + * Filter applied to the unified traffic list via the filter chips. + */ +internal enum class TrafficFilter { + ALL, + HTTP, + WS_SENT, + WS_RECEIVED, + WS_STATUS, +} + +internal object TransactionRowDiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame( + oldItem: TransactionRow, + newItem: TransactionRow, + ): Boolean = oldItem::class == newItem::class && oldItem.rowId == newItem.rowId + + override fun areContentsTheSame( + oldItem: TransactionRow, + newItem: TransactionRow, + ): Boolean = oldItem == newItem +} diff --git a/library/src/main/kotlin/com/chuckerteam/chucker/internal/ui/transaction/TransactionAdapter.kt b/library/src/main/kotlin/com/chuckerteam/chucker/internal/ui/transaction/TransactionAdapter.kt index 8028025bf..a35bfdb43 100644 --- a/library/src/main/kotlin/com/chuckerteam/chucker/internal/ui/transaction/TransactionAdapter.kt +++ b/library/src/main/kotlin/com/chuckerteam/chucker/internal/ui/transaction/TransactionAdapter.kt @@ -14,27 +14,32 @@ import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView import com.chuckerteam.chucker.R import com.chuckerteam.chucker.databinding.ChuckerListItemTransactionBinding +import com.chuckerteam.chucker.databinding.ChuckerListItemWebsocketBinding import com.chuckerteam.chucker.internal.data.entity.HttpTransaction import com.chuckerteam.chucker.internal.data.entity.HttpTransactionTuple -import com.chuckerteam.chucker.internal.support.TransactionDiffCallback +import com.chuckerteam.chucker.internal.data.entity.WebSocketTraffic +import com.chuckerteam.chucker.internal.data.entity.WebSocketTrafficTuple +import com.chuckerteam.chucker.internal.ui.model.TransactionRow +import com.chuckerteam.chucker.internal.ui.model.TransactionRowDiffCallback import java.text.DateFormat +import java.util.Date import javax.net.ssl.HttpsURLConnection /** - * Adapter for displaying a list of [HttpTransactionTuple] items in a RecyclerView. - * Supports single-tap and long-tap interaction for click and multi-select operations. + * Adapter for the unified traffic list. Renders both [TransactionRow.Http] and + * [TransactionRow.WebSocket] items in a single RecyclerView. * * @param context The application context for resource access. - * @param onTransactionClick Callback invoked on single click with transaction ID. - * @param onTransactionLongClick Callback invoked on long click with transaction ID. + * @param onTransactionClick Callback invoked on single click of an HTTP row with its id. + * @param onTransactionLongClick Callback invoked on long click of an HTTP row with its id. + * @param onWebSocketClick Callback invoked on single click of a WebSocket row with its id. */ internal class TransactionAdapter internal constructor( context: Context, private val onTransactionClick: (Long) -> Unit, private val onTransactionLongClick: (Long) -> Unit, -) : ListAdapter( - TransactionDiffCallback, - ) { + private val onWebSocketClick: (Long) -> Unit, +) : ListAdapter(TransactionRowDiffCallback) { private var isSelectionMode = false private val selectedTransactionIds = mutableSetOf() private val colorDefault: Int = ContextCompat.getColor(context, R.color.chucker_status_default) @@ -53,66 +58,49 @@ internal class TransactionAdapter internal constructor( context.theme.resolveAttribute(android.R.attr.selectableItemBackground, it, true) } + override fun getItemViewType(position: Int): Int = + when (getItem(position)) { + is TransactionRow.Http -> VIEW_TYPE_HTTP + is TransactionRow.WebSocket -> VIEW_TYPE_WEBSOCKET + } + override fun onCreateViewHolder( parent: ViewGroup, viewType: Int, - ): TransactionViewHolder { - val viewBinding = - ChuckerListItemTransactionBinding.inflate( - LayoutInflater.from(parent.context), - parent, - false, - ) - return TransactionViewHolder(viewBinding) + ): RecyclerView.ViewHolder { + val inflater = LayoutInflater.from(parent.context) + return if (viewType == VIEW_TYPE_WEBSOCKET) { + WebSocketViewHolder(ChuckerListItemWebsocketBinding.inflate(inflater, parent, false)) + } else { + TransactionViewHolder(ChuckerListItemTransactionBinding.inflate(inflater, parent, false)) + } } override fun onBindViewHolder( - holder: TransactionViewHolder, + holder: RecyclerView.ViewHolder, position: Int, - ) = holder.bind(getItem(position)) - - /** - * Updates the adapter's internal selection mode state. - * When enabled, regular taps on items will toggle selection instead of triggering click actions. - * - * This is typically called from the UI layer (e.g., Activity) in response to - * changes in the ViewModel's selection state. - * - * @param enabled True to enable selection mode, false to disable it. - */ + ) { + when (val item = getItem(position)) { + is TransactionRow.Http -> (holder as TransactionViewHolder).bind(item.tuple) + is TransactionRow.WebSocket -> (holder as WebSocketViewHolder).bind(item.tuple) + } + } + internal fun setSelectionMode(enabled: Boolean) { isSelectionMode = enabled } - /** - * Clears all currently selected transaction items and refreshes only the affected rows in the adapter. - * - * This method ensures that selection highlights are removed without unnecessarily rebinding - * unaffected items. It looks up the adapter position of each previously selected transaction ID - * and triggers a UI refresh for that specific position. - * - * Typically called when exiting selection mode or after bulk operations like deletion. - */ internal fun clearSelections() { val previouslySelectedIds = selectedTransactionIds.toSet() selectedTransactionIds.clear() - previouslySelectedIds.forEachIndexed { index, id -> - val position = currentList.indexOfFirst { it.id == id } + previouslySelectedIds.forEach { id -> + val position = + currentList.indexOfFirst { it is TransactionRow.Http && it.tuple.id == id } if (position != -1) notifyItemChanged(position) } } - /** - * Sets the list of selected transaction IDs. - * - * This is typically used to restore selection state after a configuration change (e.g., screen rotation), - * ensuring that the UI reflects the correct selection with proper background highlights. - * - * Since this affects potentially all items, a full data set refresh is triggered using [notifyDataSetChanged]. - * - * @param ids The list of transaction IDs to mark as selected. - */ @SuppressLint("NotifyDataSetChanged") internal fun setSelectedTransactionIds(ids: List) { selectedTransactionIds.clear() @@ -176,15 +164,6 @@ internal class TransactionAdapter internal constructor( setStatusColor(transaction) } - /** - * Toggles the selection state of the given transaction ID. - * If the item is already selected, it will be unselected. - * If not selected, it will be added to the selection list. - * - * Triggers a UI update for the current item to reflect the selection change. - * - * @param id The unique transaction ID to toggle selection for. - */ private fun toggleSelection(id: Long) { if (selectedTransactionIds.contains(id)) { selectedTransactionIds.remove(id) @@ -194,14 +173,6 @@ internal class TransactionAdapter internal constructor( notifyItemChanged(adapterPosition) } - /** - * Updates the visual appearance of the item based on its selection state. - * Applies a highlighted background if selected, or the default background otherwise. - * - * This should be called during binding to reflect correct UI state. - * - * @param transactionId The ID of the transaction to check against the selected set. - */ private fun updateSelectionState(transactionId: Long) { if (selectedTransactionIds.contains(transactionId)) { itemView.setBackgroundColor(colorSelected) @@ -235,6 +206,54 @@ internal class TransactionAdapter internal constructor( itemBinding.path.setTextColor(color) } } + + inner class WebSocketViewHolder( + private val itemBinding: ChuckerListItemWebsocketBinding, + ) : RecyclerView.ViewHolder(itemBinding.root) { + private var trafficId: Long? = null + + init { + itemView.setOnClickListener { + val id = trafficId ?: return@setOnClickListener + onWebSocketClick(id) + } + } + + fun bind(traffic: WebSocketTrafficTuple) { + trafficId = traffic.id + itemView.setBackgroundResource(backgroundSelectableAttr.resourceId) + + itemBinding.apply { + direction.text = directionSymbol(traffic) + summary.text = traffic.summary() + host.text = traffic.host + timeStart.text = DateFormat.getTimeInstance().format(Date(traffic.timestamp)) + typeLabel.text = traffic.typeLabel + size.text = traffic.sizeString.orEmpty() + + val color = colorFor(traffic.typeEnum) + direction.setTextColor(color) + summary.setTextColor(color) + } + } + + private fun directionSymbol(traffic: WebSocketTrafficTuple): String = + when (traffic.typeEnum) { + WebSocketTraffic.Type.SEND -> "↑" + WebSocketTraffic.Type.RECEIVE -> "↓" + WebSocketTraffic.Type.OPEN -> "↕" + WebSocketTraffic.Type.CLOSING, WebSocketTraffic.Type.CLOSED -> "×" + WebSocketTraffic.Type.FAILURE -> "!" + } + + private fun colorFor(type: WebSocketTraffic.Type): Int = + when (type) { + WebSocketTraffic.Type.FAILURE -> colorError + WebSocketTraffic.Type.OPEN -> colorRequested + WebSocketTraffic.Type.SEND -> color300 + else -> colorDefault + } + } } private fun ChuckerListItemTransactionBinding.displayGraphQlFields( @@ -249,3 +268,6 @@ private fun ChuckerListItemTransactionBinding.displayGraphQlFields( ?: root.resources.getString(R.string.chucker_graphql_operation_is_empty) } } + +private const val VIEW_TYPE_HTTP = 0 +private const val VIEW_TYPE_WEBSOCKET = 1 diff --git a/library/src/main/kotlin/com/chuckerteam/chucker/internal/ui/websocket/WebSocketTrafficActivity.kt b/library/src/main/kotlin/com/chuckerteam/chucker/internal/ui/websocket/WebSocketTrafficActivity.kt new file mode 100644 index 000000000..9a13b4909 --- /dev/null +++ b/library/src/main/kotlin/com/chuckerteam/chucker/internal/ui/websocket/WebSocketTrafficActivity.kt @@ -0,0 +1,83 @@ +package com.chuckerteam.chucker.internal.ui.websocket + +import android.annotation.SuppressLint +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.activity.viewModels +import androidx.core.view.ViewCompat +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.updatePadding +import com.chuckerteam.chucker.databinding.ChuckerActivityWebsocketTrafficBinding +import com.chuckerteam.chucker.internal.data.entity.WebSocketTraffic +import com.chuckerteam.chucker.internal.ui.BaseChuckerActivity + +internal class WebSocketTrafficActivity : BaseChuckerActivity() { + private val viewModel: WebSocketTrafficViewModel by viewModels { + WebSocketTrafficViewModelFactory(intent.getLongExtra(EXTRA_TRAFFIC_ID, 0)) + } + + private lateinit var binding: ChuckerActivityWebsocketTrafficBinding + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = ChuckerActivityWebsocketTrafficBinding.inflate(layoutInflater) + + with(binding) { + setContentView(root) + applyInsets() + setSupportActionBar(toolbar) + } + supportActionBar?.setDisplayHomeAsUpEnabled(true) + + viewModel.traffic.observe(this) { traffic -> + traffic?.let { render(it) } + } + } + + private fun applyInsets() { + WindowCompat.setDecorFitsSystemWindows(window, false) + ViewCompat.setOnApplyWindowInsetsListener(binding.root) { view, windowInsets -> + val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) + binding.appBarLayout.updatePadding(top = insets.top) + view.updatePadding(bottom = insets.bottom) + WindowInsetsCompat.CONSUMED + } + } + + @SuppressLint("SetTextI18n") + private fun render(traffic: WebSocketTraffic) { + binding.toolbarTitle.text = "WebSocket ${traffic.typeLabel}" + binding.details.text = buildDetails(traffic) + binding.content.text = traffic.getFormattedContent() + } + + private fun buildDetails(traffic: WebSocketTraffic): String = + buildString { + appendLine("Type: WebSocket ${traffic.typeLabel}") + when (traffic.direction) { + WebSocketTraffic.Direction.OUTGOING -> appendLine("Direction: ↑ Outgoing") + WebSocketTraffic.Direction.INCOMING -> appendLine("Direction: ↓ Incoming") + WebSocketTraffic.Direction.NONE -> Unit + } + appendLine("URL: ${traffic.url}") + appendLine("Time: ${traffic.timeString}") + traffic.code?.let { appendLine("Code: $it") } + traffic.error?.let { appendLine("Error: $it") } + traffic.sizeString?.let { appendLine("Size: $it") } + }.trimEnd() + + companion object { + private const val EXTRA_TRAFFIC_ID = "traffic_id" + + fun start( + context: Context, + trafficId: Long, + ) { + val intent = Intent(context, WebSocketTrafficActivity::class.java) + intent.putExtra(EXTRA_TRAFFIC_ID, trafficId) + context.startActivity(intent) + } + } +} diff --git a/library/src/main/kotlin/com/chuckerteam/chucker/internal/ui/websocket/WebSocketTrafficViewModel.kt b/library/src/main/kotlin/com/chuckerteam/chucker/internal/ui/websocket/WebSocketTrafficViewModel.kt new file mode 100644 index 000000000..db6278891 --- /dev/null +++ b/library/src/main/kotlin/com/chuckerteam/chucker/internal/ui/websocket/WebSocketTrafficViewModel.kt @@ -0,0 +1,23 @@ +package com.chuckerteam.chucker.internal.ui.websocket + +import androidx.lifecycle.LiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import com.chuckerteam.chucker.internal.data.entity.WebSocketTraffic +import com.chuckerteam.chucker.internal.data.repository.RepositoryProvider + +internal class WebSocketTrafficViewModel( + trafficId: Long, +) : ViewModel() { + val traffic: LiveData = RepositoryProvider.webSocket().getWebSocketTraffic(trafficId) +} + +internal class WebSocketTrafficViewModelFactory( + private val trafficId: Long = 0L, +) : ViewModelProvider.NewInstanceFactory() { + override fun create(modelClass: Class): T { + require(modelClass == WebSocketTrafficViewModel::class.java) { "Cannot create $modelClass" } + @Suppress("UNCHECKED_CAST") + return WebSocketTrafficViewModel(trafficId) as T + } +} diff --git a/library/src/main/res/layout/chucker_activity_main.xml b/library/src/main/res/layout/chucker_activity_main.xml index 499f287d7..d23c75e9b 100644 --- a/library/src/main/res/layout/chucker_activity_main.xml +++ b/library/src/main/res/layout/chucker_activity_main.xml @@ -27,6 +27,62 @@ + + + + + + + + + + + + + + + + diff --git a/library/src/main/res/layout/chucker_activity_websocket_traffic.xml b/library/src/main/res/layout/chucker_activity_websocket_traffic.xml new file mode 100644 index 000000000..e35414884 --- /dev/null +++ b/library/src/main/res/layout/chucker_activity_websocket_traffic.xml @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/library/src/main/res/layout/chucker_list_item_websocket.xml b/library/src/main/res/layout/chucker_list_item_websocket.xml new file mode 100644 index 000000000..57771cd49 --- /dev/null +++ b/library/src/main/res/layout/chucker_list_item_websocket.xml @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + + + + diff --git a/library/src/main/res/values-es/strings.xml b/library/src/main/res/values-es/strings.xml index cb1ac0f48..5979ac532 100644 --- a/library/src/main/res/values-es/strings.xml +++ b/library/src/main/res/values-es/strings.xml @@ -79,4 +79,9 @@ Copiar Con Formato Solicitud Con Formato Copiada Respuesta Con Formato Copiada + Todos + HTTP + WS Enviados + WS Recibidos + WS Estado diff --git a/library/src/main/res/values-ja/strings.xml b/library/src/main/res/values-ja/strings.xml index e9a335ef6..7a0570f95 100644 --- a/library/src/main/res/values-ja/strings.xml +++ b/library/src/main/res/values-ja/strings.xml @@ -79,4 +79,9 @@ 整形してコピー 整形したリクエストをコピーしました 整形したレスポンスをコピーしました + すべて + HTTP + WS 送信 + WS 受信 + WS 状態 diff --git a/library/src/main/res/values-ko/strings.xml b/library/src/main/res/values-ko/strings.xml index 63a8934fb..b52c062b1 100644 --- a/library/src/main/res/values-ko/strings.xml +++ b/library/src/main/res/values-ko/strings.xml @@ -79,4 +79,9 @@ 포맷된 형태로 복사 포맷된 요청이 복사되었습니다 포맷된 응답이 복사되었습니다 + 전체 + HTTP + WS 전송 + WS 수신 + WS 상태 diff --git a/library/src/main/res/values-ru/strings.xml b/library/src/main/res/values-ru/strings.xml index 5b4b28c46..c1f7ac504 100644 --- a/library/src/main/res/values-ru/strings.xml +++ b/library/src/main/res/values-ru/strings.xml @@ -79,4 +79,9 @@ Копировать с форматированием Запрос скопирован с форматированием Ответ скопирован с форматированием + Все + HTTP + WS Отпр. + WS Вход. + WS Статус diff --git a/library/src/main/res/values/strings.xml b/library/src/main/res/values/strings.xml index face2b2f4..ac02a590f 100644 --- a/library/src/main/res/values/strings.xml +++ b/library/src/main/res/values/strings.xml @@ -80,4 +80,9 @@ Copy Formatted Formatted Request Copied Formatted Response Copied + All + HTTP + WS Sent + WS Received + WS Status diff --git a/library/src/test/kotlin/com/chuckerteam/chucker/api/ChuckerWebSocketListenerTest.kt b/library/src/test/kotlin/com/chuckerteam/chucker/api/ChuckerWebSocketListenerTest.kt new file mode 100644 index 000000000..72e3e722c --- /dev/null +++ b/library/src/test/kotlin/com/chuckerteam/chucker/api/ChuckerWebSocketListenerTest.kt @@ -0,0 +1,110 @@ +package com.chuckerteam.chucker.api + +import com.chuckerteam.chucker.internal.data.entity.WebSocketTraffic +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.runs +import io.mockk.verify +import okhttp3.Protocol +import okhttp3.Request +import okhttp3.Response +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import okio.ByteString.Companion.encodeUtf8 +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +internal class ChuckerWebSocketListenerTest { + private val collector = mockk(relaxed = true) + private val delegate = mockk(relaxed = true) + private val webSocket = mockk(relaxed = true) + private val request = Request.Builder().url("wss://example.com/socket?token=abc").build() + + private val captured = mutableListOf() + + private fun listener(): ChuckerWebSocketListener { + every { collector.onWebSocketTraffic(capture(captured)) } just runs + return ChuckerWebSocketListener(delegate, collector, request) + } + + private fun handshakeResponse(): Response = + Response + .Builder() + .request(request) + .protocol(Protocol.HTTP_1_1) + .code(101) + .message("Switching Protocols") + .build() + + @Test + fun `onOpen records an OPEN event and delegates`() { + val listener = listener() + val response = handshakeResponse() + + listener.onOpen(webSocket, response) + + assertEquals(WebSocketTraffic.Type.OPEN, captured.single().typeEnum) + assertTrue(captured.single().content!!.contains("101")) + assertEquals("wss", captured.single().scheme) + verify { delegate.onOpen(webSocket, response) } + } + + @Test + fun `onMessage text records an incoming TEXT event and delegates`() { + val listener = listener() + + listener.onMessage(webSocket, "hello") + + val event = captured.single() + assertEquals(WebSocketTraffic.Type.RECEIVE, event.typeEnum) + assertEquals(WebSocketTraffic.Direction.INCOMING, event.direction) + assertEquals("TEXT", event.contentType) + assertEquals("hello", event.content) + assertEquals(5L, event.size) + verify { delegate.onMessage(webSocket, "hello") } + } + + @Test + fun `onMessage bytes records an incoming BINARY event and delegates`() { + val listener = listener() + val bytes = "binary".encodeUtf8() + + listener.onMessage(webSocket, bytes) + + val event = captured.single() + assertEquals(WebSocketTraffic.Type.RECEIVE, event.typeEnum) + assertEquals("BINARY", event.contentType) + assertEquals(bytes.size.toLong(), event.size) + verify { delegate.onMessage(webSocket, bytes) } + } + + @Test + fun `onClosing and onClosed record status events with the close code`() { + val listener = listener() + + listener.onClosing(webSocket, 1001, "going away") + listener.onClosed(webSocket, 1000, "done") + + assertEquals(WebSocketTraffic.Type.CLOSING, captured[0].typeEnum) + assertEquals(1001, captured[0].code) + assertEquals(WebSocketTraffic.Type.CLOSED, captured[1].typeEnum) + assertEquals(1000, captured[1].code) + verify { delegate.onClosing(webSocket, 1001, "going away") } + verify { delegate.onClosed(webSocket, 1000, "done") } + } + + @Test + fun `onFailure records a FAILURE event with the error message`() { + val listener = listener() + val error = RuntimeException("boom") + + listener.onFailure(webSocket, error, null) + + val event = captured.single() + assertEquals(WebSocketTraffic.Type.FAILURE, event.typeEnum) + assertEquals("boom", event.error) + verify { delegate.onFailure(webSocket, error, null) } + } +} diff --git a/library/src/test/kotlin/com/chuckerteam/chucker/internal/data/repository/WebSocketTrafficDatabaseRepositoryTest.kt b/library/src/test/kotlin/com/chuckerteam/chucker/internal/data/repository/WebSocketTrafficDatabaseRepositoryTest.kt new file mode 100644 index 000000000..479f63660 --- /dev/null +++ b/library/src/test/kotlin/com/chuckerteam/chucker/internal/data/repository/WebSocketTrafficDatabaseRepositoryTest.kt @@ -0,0 +1,85 @@ +package com.chuckerteam.chucker.internal.data.repository + +import android.content.Context +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import com.chuckerteam.chucker.internal.data.entity.WebSocketTraffic +import com.chuckerteam.chucker.internal.data.room.ChuckerDatabase +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class WebSocketTrafficDatabaseRepositoryTest { + @Rule + @JvmField + val instantExecutorRule = InstantTaskExecutorRule() + + private lateinit var db: ChuckerDatabase + private lateinit var testObject: WebSocketTrafficDatabaseRepository + + @Before + fun setUp() { + val context: Context = ApplicationProvider.getApplicationContext() + db = + Room + .inMemoryDatabaseBuilder(context, ChuckerDatabase::class.java) + .allowMainThreadQueries() + .build() + testObject = WebSocketTrafficDatabaseRepository(db) + } + + @After + fun tearDown() = db.close() + + @Test + fun `inserted traffic is read back within time range`() { + runBlocking { + testObject.insertWebSocketTraffic(traffic(timestamp = 100, type = WebSocketTraffic.Type.SEND)) + testObject.insertWebSocketTraffic(traffic(timestamp = 200, type = WebSocketTraffic.Type.RECEIVE)) + + val all = testObject.getWebSocketTrafficInTimeRange(0) + + assertThat(all).hasSize(2) + assertThat(all.map { it.type }).containsExactly("SEND", "RECEIVE") + } + } + + @Test + fun `old traffic is deleted before threshold`() { + runBlocking { + testObject.insertWebSocketTraffic(traffic(timestamp = 100, type = WebSocketTraffic.Type.SEND)) + testObject.insertWebSocketTraffic(traffic(timestamp = 300, type = WebSocketTraffic.Type.RECEIVE)) + + testObject.deleteOldWebSocketTraffic(threshold = 200) + + val remaining = testObject.getAllWebSocketTraffic() + assertThat(remaining).hasSize(1) + assertThat(remaining.single().timestamp).isEqualTo(300) + } + } + + private fun traffic( + timestamp: Long, + type: WebSocketTraffic.Type, + ) = WebSocketTraffic( + timestamp = timestamp, + connectionId = 1L, + type = type.name, + url = "wss://example.com/ws", + host = "example.com", + path = "/ws", + scheme = "wss", + content = "payload", + contentType = "TEXT", + size = 7, + code = null, + error = null, + ) +} diff --git a/library/src/test/kotlin/com/chuckerteam/chucker/internal/support/RecordingWebSocketTest.kt b/library/src/test/kotlin/com/chuckerteam/chucker/internal/support/RecordingWebSocketTest.kt new file mode 100644 index 000000000..4a5427a93 --- /dev/null +++ b/library/src/test/kotlin/com/chuckerteam/chucker/internal/support/RecordingWebSocketTest.kt @@ -0,0 +1,76 @@ +package com.chuckerteam.chucker.internal.support + +import com.chuckerteam.chucker.api.ChuckerCollector +import com.chuckerteam.chucker.internal.data.entity.WebSocketTraffic +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.runs +import io.mockk.verify +import okhttp3.Request +import okhttp3.WebSocket +import okio.ByteString.Companion.encodeUtf8 +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +internal class RecordingWebSocketTest { + private val collector = mockk(relaxed = true) + private val delegate = mockk(relaxed = true) + private val request = Request.Builder().url("wss://example.com/ws").build() + private val captured = mutableListOf() + + private fun recordingWebSocket(): RecordingWebSocket { + every { collector.onWebSocketTraffic(capture(captured)) } just runs + val recorder = + WebSocketTrafficRecorder( + request = request, + connectionId = 1L, + collector = collector, + maxContentLength = 250_000L, + ) + return RecordingWebSocket(delegate, recorder) + } + + @Test + fun `send text records an outgoing TEXT event and delegates`() { + every { delegate.send(any()) } returns true + val recording = recordingWebSocket() + + val result = recording.send("ping") + + assertTrue(result) + val event = captured.single() + assertEquals(WebSocketTraffic.Type.SEND, event.typeEnum) + assertEquals(WebSocketTraffic.Direction.OUTGOING, event.direction) + assertEquals("TEXT", event.contentType) + assertEquals("ping", event.content) + verify { delegate.send("ping") } + } + + @Test + fun `send bytes records an outgoing BINARY event and delegates`() { + val bytes = "payload".encodeUtf8() + every { delegate.send(bytes) } returns true + val recording = recordingWebSocket() + + recording.send(bytes) + + val event = captured.single() + assertEquals(WebSocketTraffic.Type.SEND, event.typeEnum) + assertEquals("BINARY", event.contentType) + assertEquals(bytes.size.toLong(), event.size) + verify { delegate.send(bytes) } + } + + @Test + fun `close does not record and delegates`() { + every { delegate.close(any(), any()) } returns true + val recording = recordingWebSocket() + + recording.close(1000, "bye") + + assertTrue(captured.isEmpty()) + verify { delegate.close(1000, "bye") } + } +} diff --git a/library/src/test/kotlin/com/chuckerteam/chucker/internal/support/TrafficListSharableTest.kt b/library/src/test/kotlin/com/chuckerteam/chucker/internal/support/TrafficListSharableTest.kt new file mode 100644 index 000000000..4f03acd53 --- /dev/null +++ b/library/src/test/kotlin/com/chuckerteam/chucker/internal/support/TrafficListSharableTest.kt @@ -0,0 +1,78 @@ +package com.chuckerteam.chucker.internal.support + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.chuckerteam.chucker.internal.data.entity.HttpTransaction +import com.chuckerteam.chucker.internal.data.entity.WebSocketTraffic +import com.google.common.truth.Truth.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class TrafficListSharableTest { + private lateinit var context: Context + + @Before + fun setUp() { + context = ApplicationProvider.getApplicationContext() + } + + @Test + fun `websocket sharable renders type, url and content`() { + val traffic = webSocketTraffic(id = 1, timestamp = 1_000, content = "{\"event\":\"messenger\"}") + + val text = WebSocketTrafficSharable(traffic).toSharableUtf8Content(context) + + assertThat(text).contains("WebSocket RECEIVED") + assertThat(text).contains("wss://example.com/ws") + assertThat(text).contains("messenger") + } + + @Test + fun `traffic list mixes http and websocket ordered chronologically`() { + val http = + HttpTransaction().apply { + requestDate = 2_000 + url = "https://example.com/api" + method = "GET" + } + val early = webSocketTraffic(id = 1, timestamp = 1_000, content = "early-frame") + val late = webSocketTraffic(id = 2, timestamp = 3_000, content = "late-frame") + + val text = + TrafficListSharable( + httpTransactions = listOf(http), + webSocketTraffic = listOf(late, early), + ).toSharableUtf8Content(context) + + val indexEarly = text.indexOf("early-frame") + val indexHttp = text.indexOf("example.com/api") + val indexLate = text.indexOf("late-frame") + + assertThat(indexEarly).isGreaterThan(-1) + assertThat(indexHttp).isGreaterThan(indexEarly) + assertThat(indexLate).isGreaterThan(indexHttp) + } + + private fun webSocketTraffic( + id: Long, + timestamp: Long, + content: String, + ) = WebSocketTraffic( + id = id, + timestamp = timestamp, + connectionId = 1L, + type = WebSocketTraffic.Type.RECEIVE.name, + url = "wss://example.com/ws", + host = "example.com", + path = "/ws", + scheme = "wss", + content = content, + contentType = "TEXT", + size = content.length.toLong(), + code = null, + error = null, + ) +} diff --git a/library/src/test/kotlin/com/chuckerteam/chucker/internal/ui/MainViewModelTest.kt b/library/src/test/kotlin/com/chuckerteam/chucker/internal/ui/MainViewModelTest.kt index 5a8dcbca8..9807f20b9 100644 --- a/library/src/test/kotlin/com/chuckerteam/chucker/internal/ui/MainViewModelTest.kt +++ b/library/src/test/kotlin/com/chuckerteam/chucker/internal/ui/MainViewModelTest.kt @@ -6,9 +6,12 @@ import androidx.lifecycle.MutableLiveData import androidx.lifecycle.Observer import com.chuckerteam.chucker.internal.data.entity.HttpTransaction import com.chuckerteam.chucker.internal.data.entity.HttpTransactionTuple +import com.chuckerteam.chucker.internal.data.entity.WebSocketTrafficTuple import com.chuckerteam.chucker.internal.data.repository.HttpTransactionRepository import com.chuckerteam.chucker.internal.data.repository.RepositoryProvider +import com.chuckerteam.chucker.internal.data.repository.WebSocketTrafficRepository import com.chuckerteam.chucker.internal.support.NotificationHelper +import com.chuckerteam.chucker.internal.ui.model.TransactionRow import io.mockk.MockKAnnotations import io.mockk.coEvery import io.mockk.coVerify @@ -44,11 +47,15 @@ internal class MainViewModelTest { private lateinit var transactionRepository: HttpTransactionRepository @MockK - private lateinit var transactionObserver: Observer> + private lateinit var webSocketRepository: WebSocketTrafficRepository + + @MockK + private lateinit var transactionObserver: Observer> private lateinit var viewModel: MainViewModel private val emptyTransactionList = MutableLiveData>() + private val emptyWebSocketList = MutableLiveData>() @Before fun setup() { @@ -66,8 +73,11 @@ internal class MainViewModelTest { ) } returns emptyTransactionList + every { webSocketRepository.getSortedWebSocketTrafficTuples() } returns emptyWebSocketList + mockkObject(RepositoryProvider) every { RepositoryProvider.transaction() } returns transactionRepository + every { RepositoryProvider.webSocket() } returns webSocketRepository mockkObject(NotificationHelper) every { NotificationHelper.clearBuffer() } just runs @@ -91,12 +101,12 @@ internal class MainViewModelTest { every { transactionRepository.getSortedTransactionTuples() } returns transactionLiveData every { TextUtils.isDigitsOnly(any()) } returns false - viewModel.transactions.observeForever(transactionObserver) + viewModel.listItems.observeForever(transactionObserver) viewModel.updateItemsFilter("") transactionLiveData.value = expectedTuples verify { transactionRepository.getSortedTransactionTuples() } - verify { transactionObserver.onChanged(expectedTuples) } + verify { transactionObserver.onChanged(expectedTuples.map { TransactionRow.Http(it) }) } } @Test @@ -113,12 +123,12 @@ internal class MainViewModelTest { } returns transactionLiveData every { TextUtils.isDigitsOnly(searchQuery) } returns true - viewModel.transactions.observeForever(transactionObserver) + viewModel.listItems.observeForever(transactionObserver) viewModel.updateItemsFilter(searchQuery) transactionLiveData.value = expectedTuples verify { transactionRepository.getFilteredTransactionTuples(searchQuery, "") } - verify { transactionObserver.onChanged(expectedTuples) } + verify { transactionObserver.onChanged(expectedTuples.map { TransactionRow.Http(it) }) } } @Test @@ -135,12 +145,12 @@ internal class MainViewModelTest { } returns transactionLiveData every { TextUtils.isDigitsOnly(searchQuery) } returns false - viewModel.transactions.observeForever(transactionObserver) + viewModel.listItems.observeForever(transactionObserver) viewModel.updateItemsFilter(searchQuery) transactionLiveData.value = expectedTuples verify { transactionRepository.getFilteredTransactionTuples("", searchQuery) } - verify { transactionObserver.onChanged(expectedTuples) } + verify { transactionObserver.onChanged(expectedTuples.map { TransactionRow.Http(it) }) } } @Test 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..dd6b042e5 100644 --- a/sample/src/main/kotlin/com/chuckerteam/chucker/sample/MainActivity.kt +++ b/sample/src/main/kotlin/com/chuckerteam/chucker/sample/MainActivity.kt @@ -66,6 +66,9 @@ class MainActivity : ComponentActivity() { onDoGraphQL = { GraphQlTask(client).run() }, + onDoWebSocket = { + WebSocketTask(applicationContext).run() + }, onLaunchChucker = { launchChuckerDirectly() }, diff --git a/sample/src/main/kotlin/com/chuckerteam/chucker/sample/WebSocketTask.kt b/sample/src/main/kotlin/com/chuckerteam/chucker/sample/WebSocketTask.kt new file mode 100644 index 000000000..43228406b --- /dev/null +++ b/sample/src/main/kotlin/com/chuckerteam/chucker/sample/WebSocketTask.kt @@ -0,0 +1,75 @@ +package com.chuckerteam.chucker.sample + +import android.content.Context +import android.util.Log +import com.chuckerteam.chucker.api.ChuckerCollector +import com.chuckerteam.chucker.api.ChuckerWebSockets +import com.chuckerteam.chucker.api.RetentionManager +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import java.util.concurrent.atomic.AtomicInteger + +/** + * Opens a WebSocket to a public echo server through [ChuckerWebSockets.create], sends a couple of + * messages and closes after receiving their echoes. Every event (open, sent, received, closed) is + * recorded by Chucker so it shows up mixed into the transaction list. + */ +class WebSocketTask( + private val context: Context, +) { + fun run() { + val collector = + ChuckerCollector( + context = context, + showNotification = true, + retentionPeriod = RetentionManager.Period.ONE_HOUR, + ) + val client = OkHttpClient.Builder().build() + val request = Request.Builder().url(ECHO_URL).build() + + val receivedCount = AtomicInteger(0) + var socket: WebSocket? = null + val listener = + object : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: Response, + ) { + // Send once the connection is open, via the returned (recording) socket so that + // outgoing frames are captured AND logged after OPEN (natural chronological order). + socket?.send("Hello from the Chucker sample 👋") + socket?.send("""{"type":"ping","payload":{"n":1}}""") + } + + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + Log.d(TAG, "WebSocket echo received: $text") + if (receivedCount.incrementAndGet() >= MESSAGES_TO_SEND) { + socket?.close(NORMAL_CLOSURE, "Sample done") + } + } + + override fun onFailure( + webSocket: WebSocket, + t: Throwable, + response: Response?, + ) { + Log.e(TAG, "WebSocket failure", t) + } + } + + socket = ChuckerWebSockets.create(client, request, collector, listener) + } + + private companion object { + private const val TAG = "WebSocketTask" + private const val ECHO_URL = "wss://ws.postman-echo.com/raw" + private const val MESSAGES_TO_SEND = 2 + private const val NORMAL_CLOSURE = 1000 + } +} 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..47d83d13e 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 @@ -51,6 +51,7 @@ internal fun ChuckerSampleControls( onInterceptorTypeLabelClick: () -> Unit, onDoHttp: () -> Unit, onDoGraphQL: () -> Unit, + onDoWebSocket: () -> Unit, onLaunchChucker: () -> Unit, onExportToLogFile: () -> Unit, onExportToHarFile: () -> Unit, @@ -125,6 +126,14 @@ internal fun ChuckerSampleControls( } } + Button( + onClick = onDoWebSocket, + modifier = modifier.testTag(ChuckerTestTags.CONTROLS_DO_WEBSOCKET_BUTTON), + shape = RoundedCornerShape(4.dp), + ) { + Text(text = stringResource(R.string.do_websocket_activity)) + } + if (isChuckerInOpMode) { Button( onClick = onLaunchChucker, @@ -219,6 +228,9 @@ private fun ChuckerSampleControlsPreview() { onDoGraphQL = { // DO Nothing }, + onDoWebSocket = { + // DO Nothing + }, onLaunchChucker = { // DO Nothing }, @@ -267,6 +279,9 @@ private fun ChuckerSampleControlsTabletPreview() { onDoGraphQL = { // DO Nothing }, + onDoWebSocket = { + // DO Nothing + }, onLaunchChucker = { // 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..76029d8d5 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 isChuckerInOpMode If true, displays the Chucker-specific operation buttons. */ @OptIn(ExperimentalMaterial3Api::class) +@Suppress("LongParameterList") @Composable internal fun ChuckerSampleMainScreen( widthSizeClass: WindowWidthSizeClass, @@ -61,6 +62,7 @@ internal fun ChuckerSampleMainScreen( onInterceptorTypeLabelClick: () -> Unit, onDoHttp: () -> Unit, onDoGraphQL: () -> Unit, + onDoWebSocket: () -> Unit, onLaunchChucker: () -> Unit, onExportToLogFile: () -> Unit, onExportToHarFile: () -> Unit, @@ -111,6 +113,7 @@ internal fun ChuckerSampleMainScreen( onInterceptorTypeLabelClick = onInterceptorTypeLabelClick, onDoHttp = onDoHttp, onDoGraphQL = onDoGraphQL, + onDoWebSocket = onDoWebSocket, onLaunchChucker = onLaunchChucker, onExportToLogFile = onExportToLogFile, onExportToHarFile = onExportToHarFile, @@ -159,6 +162,7 @@ internal fun ChuckerSampleMainScreen( onInterceptorTypeLabelClick = onInterceptorTypeLabelClick, onDoHttp = onDoHttp, onDoGraphQL = onDoGraphQL, + onDoWebSocket = onDoWebSocket, onLaunchChucker = onLaunchChucker, onExportToLogFile = onExportToLogFile, onExportToHarFile = onExportToHarFile, @@ -218,6 +222,9 @@ private fun ChuckerSampleMainScreenPreview() { onDoGraphQL = { // DO Nothing }, + onDoWebSocket = { + // DO Nothing + }, onLaunchChucker = { // DO Nothing }, @@ -264,6 +271,9 @@ private fun ChuckerSampleMainScreenTabletPreview() { onDoGraphQL = { // DO Nothing }, + onDoWebSocket = { + // DO Nothing + }, onLaunchChucker = { // 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..40d4062d5 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 @@ -9,6 +9,7 @@ object ChuckerTestTags { const val CONTROLS_EXPORT_HAR_BUTTON = "controls_export_har_button" const val CONTROLS_DO_HTTP_BUTTON = "controls_do_http_button" const val CONTROLS_DO_GRAPHQL_BUTTON = "controls_do_graphql_button" + const val CONTROLS_DO_WEBSOCKET_BUTTON = "controls_do_websocket_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/res/values/strings.xml b/sample/src/main/res/values/strings.xml index 557b3e9d0..37f459515 100644 --- a/sample/src/main/res/values/strings.xml +++ b/sample/src/main/res/values/strings.xml @@ -5,6 +5,7 @@ Network Do HTTP activity Do GraphQL activity + Do WebSocket activity Launch Chucker directly Export to LOG file Export to HAR file From 1029e0def6a2e8391d262ad7e716e8e8fa7fa43f Mon Sep 17 00:00:00 2001 From: simonvar Date: Mon, 13 Jul 2026 16:02:27 +0300 Subject: [PATCH 2/2] ci: update API dumps and fix ktlint/detekt for WebSocket API Co-Authored-By: Claude Opus 4.8 (1M context) --- library-no-op/api/library-no-op.api | 19 +++++++++++++++ library/api/library.api | 23 +++++++++++++++++++ .../WebSocketTrafficDatabaseRepository.kt | 4 +--- .../chucker/internal/ui/MainViewModel.kt | 3 +-- 4 files changed, 44 insertions(+), 5 deletions(-) diff --git a/library-no-op/api/library-no-op.api b/library-no-op/api/library-no-op.api index 68fdd699f..2f5e965d5 100644 --- a/library-no-op/api/library-no-op.api +++ b/library-no-op/api/library-no-op.api @@ -44,6 +44,25 @@ public final class com/chuckerteam/chucker/api/ChuckerInterceptor$Builder { public final fun skipPaths ([Ljava/lang/String;)Lcom/chuckerteam/chucker/api/ChuckerInterceptor$Builder; } +public final class com/chuckerteam/chucker/api/ChuckerWebSocketListener : okhttp3/WebSocketListener { + public fun (Lokhttp3/WebSocketListener;Lcom/chuckerteam/chucker/api/ChuckerCollector;Lokhttp3/Request;)V + public fun (Lokhttp3/WebSocketListener;Lcom/chuckerteam/chucker/api/ChuckerCollector;Lokhttp3/Request;J)V + public synthetic fun (Lokhttp3/WebSocketListener;Lcom/chuckerteam/chucker/api/ChuckerCollector;Lokhttp3/Request;JILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun onClosed (Lokhttp3/WebSocket;ILjava/lang/String;)V + public fun onClosing (Lokhttp3/WebSocket;ILjava/lang/String;)V + public fun onFailure (Lokhttp3/WebSocket;Ljava/lang/Throwable;Lokhttp3/Response;)V + public fun onMessage (Lokhttp3/WebSocket;Ljava/lang/String;)V + public fun onMessage (Lokhttp3/WebSocket;Lokio/ByteString;)V + public fun onOpen (Lokhttp3/WebSocket;Lokhttp3/Response;)V +} + +public final class com/chuckerteam/chucker/api/ChuckerWebSockets { + public static final field INSTANCE Lcom/chuckerteam/chucker/api/ChuckerWebSockets; + public static final fun create (Lokhttp3/OkHttpClient;Lokhttp3/Request;Lcom/chuckerteam/chucker/api/ChuckerCollector;Lokhttp3/WebSocketListener;)Lokhttp3/WebSocket; + public static final fun create (Lokhttp3/OkHttpClient;Lokhttp3/Request;Lcom/chuckerteam/chucker/api/ChuckerCollector;Lokhttp3/WebSocketListener;J)Lokhttp3/WebSocket; + public static synthetic fun create$default (Lokhttp3/OkHttpClient;Lokhttp3/Request;Lcom/chuckerteam/chucker/api/ChuckerCollector;Lokhttp3/WebSocketListener;JILjava/lang/Object;)Lokhttp3/WebSocket; +} + public final class com/chuckerteam/chucker/api/ExportFormat : java/lang/Enum { public static final field HAR Lcom/chuckerteam/chucker/api/ExportFormat; public static final field LOG Lcom/chuckerteam/chucker/api/ExportFormat; diff --git a/library/api/library.api b/library/api/library.api index 330dbeb19..6063c8cd7 100644 --- a/library/api/library.api +++ b/library/api/library.api @@ -44,6 +44,29 @@ public final class com/chuckerteam/chucker/api/ChuckerInterceptor$Builder { public final fun skipPaths ([Ljava/lang/String;)Lcom/chuckerteam/chucker/api/ChuckerInterceptor$Builder; } +public final class com/chuckerteam/chucker/api/ChuckerWebSocketListener : okhttp3/WebSocketListener { + public static final field Companion Lcom/chuckerteam/chucker/api/ChuckerWebSocketListener$Companion; + public fun (Lokhttp3/WebSocketListener;Lcom/chuckerteam/chucker/api/ChuckerCollector;Lokhttp3/Request;)V + public fun (Lokhttp3/WebSocketListener;Lcom/chuckerteam/chucker/api/ChuckerCollector;Lokhttp3/Request;J)V + public synthetic fun (Lokhttp3/WebSocketListener;Lcom/chuckerteam/chucker/api/ChuckerCollector;Lokhttp3/Request;JILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun onClosed (Lokhttp3/WebSocket;ILjava/lang/String;)V + public fun onClosing (Lokhttp3/WebSocket;ILjava/lang/String;)V + public fun onFailure (Lokhttp3/WebSocket;Ljava/lang/Throwable;Lokhttp3/Response;)V + public fun onMessage (Lokhttp3/WebSocket;Ljava/lang/String;)V + public fun onMessage (Lokhttp3/WebSocket;Lokio/ByteString;)V + public fun onOpen (Lokhttp3/WebSocket;Lokhttp3/Response;)V +} + +public final class com/chuckerteam/chucker/api/ChuckerWebSocketListener$Companion { +} + +public final class com/chuckerteam/chucker/api/ChuckerWebSockets { + public static final field INSTANCE Lcom/chuckerteam/chucker/api/ChuckerWebSockets; + public static final fun create (Lokhttp3/OkHttpClient;Lokhttp3/Request;Lcom/chuckerteam/chucker/api/ChuckerCollector;Lokhttp3/WebSocketListener;)Lokhttp3/WebSocket; + public static final fun create (Lokhttp3/OkHttpClient;Lokhttp3/Request;Lcom/chuckerteam/chucker/api/ChuckerCollector;Lokhttp3/WebSocketListener;J)Lokhttp3/WebSocket; + public static synthetic fun create$default (Lokhttp3/OkHttpClient;Lokhttp3/Request;Lcom/chuckerteam/chucker/api/ChuckerCollector;Lokhttp3/WebSocketListener;JILjava/lang/Object;)Lokhttp3/WebSocket; +} + public final class com/chuckerteam/chucker/api/ExportFormat : java/lang/Enum { public static final field HAR Lcom/chuckerteam/chucker/api/ExportFormat; public static final field LOG Lcom/chuckerteam/chucker/api/ExportFormat; diff --git a/library/src/main/kotlin/com/chuckerteam/chucker/internal/data/repository/WebSocketTrafficDatabaseRepository.kt b/library/src/main/kotlin/com/chuckerteam/chucker/internal/data/repository/WebSocketTrafficDatabaseRepository.kt index fc3414125..87e3074e2 100644 --- a/library/src/main/kotlin/com/chuckerteam/chucker/internal/data/repository/WebSocketTrafficDatabaseRepository.kt +++ b/library/src/main/kotlin/com/chuckerteam/chucker/internal/data/repository/WebSocketTrafficDatabaseRepository.kt @@ -2,7 +2,6 @@ package com.chuckerteam.chucker.internal.data.repository import androidx.lifecycle.LiveData import com.chuckerteam.chucker.internal.data.entity.WebSocketTraffic -import com.chuckerteam.chucker.internal.data.entity.WebSocketTrafficTuple import com.chuckerteam.chucker.internal.data.room.ChuckerDatabase internal class WebSocketTrafficDatabaseRepository( @@ -15,8 +14,7 @@ internal class WebSocketTrafficDatabaseRepository( traffic.id = id ?: 0 } - override fun getSortedWebSocketTrafficTuples(): LiveData> = - webSocketDao.getSortedTuples() + override fun getSortedWebSocketTrafficTuples() = webSocketDao.getSortedTuples() override fun getWebSocketTraffic(id: Long): LiveData = webSocketDao.getById(id) diff --git a/library/src/main/kotlin/com/chuckerteam/chucker/internal/ui/MainViewModel.kt b/library/src/main/kotlin/com/chuckerteam/chucker/internal/ui/MainViewModel.kt index ac2c48309..73022a58c 100644 --- a/library/src/main/kotlin/com/chuckerteam/chucker/internal/ui/MainViewModel.kt +++ b/library/src/main/kotlin/com/chuckerteam/chucker/internal/ui/MainViewModel.kt @@ -129,8 +129,7 @@ internal class MainViewModel : ViewModel() { /** * Returns all captured WebSocket events, used for text/log export. */ - suspend fun getWebSocketTraffic(): List = - RepositoryProvider.webSocket().getAllWebSocketTraffic() + suspend fun getWebSocketTraffic(): List = RepositoryProvider.webSocket().getAllWebSocketTraffic() /** * Toggles the selection state of the given transaction ID.